Compare commits

..

41 commits

Author SHA1 Message Date
Mike Cao
24b017cad8
Merge pull request #3765 from umami-software/dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
v3.0.1
2025-11-17 22:39:48 -08:00
Mike Cao
ef3f7274e3 Remember last team.
Some checks are pending
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-17 19:12:25 -08:00
Mike Cao
1852acc333 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-14 15:46:59 -08:00
Mike Cao
cb63e49a9b Fixed triggered event lookup. Closes #3742. 2025-11-14 15:42:23 -08:00
Mike Cao
d382ad2975
Merge pull request #3682 from rkoh-rq/patch-1
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
fix: quote "event" reserved keyword in journey queries
2025-11-14 11:44:31 -08:00
Mike Cao
b1dc690e2f
Merge branch 'dev' into patch-1 2025-11-14 11:44:20 -08:00
Francis Cao
cc8254985b Increase resetWebsite timeout. fix retention bug returning decimal day_number in CH.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
Closes #3698
2025-11-14 09:11:26 -08:00
Francis Cao
a3f32b036d revert getDateStringSQL for CH 2025-11-14 08:10:13 -08:00
Mike Cao
5ded9abbfe Added data-fetch-credentials attribute. Closes #3644
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-13 19:42:04 -08:00
Francis Cao
6751bf88bb fix chart and timezone issues, pass consistent dates to DB.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
Closes #3700
2025-11-13 15:52:24 -08:00
Mike Cao
81bedec6d5
Merge pull request #3749 from Maxime-J/os-formatting
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Restore OS formatting in tables
2025-11-13 13:06:39 -08:00
Maxime-J
4531538ad3 Restore OS formatting in tables 2025-11-13 15:46:05 +01:00
Mike Cao
9fbcec46af
Merge pull request #3737 from prince0xdev/fix/login-autocomplete-username
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
fix: correct autocomplete attributes to enable password manager autofill
2025-11-12 21:38:12 -08:00
Mike Cao
d98cc35208
Merge pull request #3743 from Mintimate/master
feat(geo): add redirect support for direct .mmdb downloads
2025-11-12 21:33:19 -08:00
Mike Cao
97ebdc1bab Merge remote-tracking branch 'origin/dev' into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-12 16:40:50 -08:00
Mike Cao
8a66603d32 Responsive fixes. 2025-11-12 16:39:58 -08:00
Mintimate
e13362bfec feat(geo): add redirect support for direct .mmdb downloads 2025-11-12 19:18:44 +08:00
Mintimate
371ff47325 feat(geo): add support for direct .mmdb URL and custom GEO_DATABASE_URL
- Support GEO_DATABASE_URL environment variable for custom database URL

- Auto-detect .mmdb files and skip decompression

- Maintain backward compatibility with tar.gz archives
2025-11-12 17:51:19 +08:00
Francis Cao
3aa09572f5 Merge branch 'master' of https://github.com/umami-software/umami into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-11 21:40:28 -08:00
Prince EKPINSE
a56746ce6d fix: enable password manager autofill on login form (#3735) 2025-11-12 00:15:05 +01:00
Prince EKPINSE
678a2ccdf3 fix: correct autocomplete attributes to enable password manager autofill 2025-11-12 00:08:36 +01:00
Francis Cao
bf498d9239 add RealtimeData to types
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-11 13:45:41 -08:00
Francis Cao
30781430c5 remove timezone from realtime. Closes #3700 2025-11-11 13:13:25 -08:00
Francis Cao
14f5babea7
Merge pull request #3731 from Maxime-J/unique-constraint
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Prevent duplicate key db errors on session creation
2025-11-11 11:13:14 -08:00
Maxime-J
14f3db550b Use raw query with on conflict in createSession 2025-11-11 10:32:31 +01:00
Mike Cao
3d8402d2f1 Merge branch 'master' into dev 2025-11-10 22:44:36 -08:00
Mike Cao
592f7c0ae7 Added check for REDIS_URL. Closes #3677.
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-10 21:08:55 -08:00
Francis Cao
8787764e0e Merge branch 'analytics' of https://github.com/umami-software/umami into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-10 17:32:18 -08:00
Francis Cao
839bf3898f add canonicalizeTimezone conversions
Co-authored-by: Om Mishra <contact@om-mishra.com>
2025-11-10 17:27:45 -08:00
Francis Cao
13ab84d50e Revert "add canonicalizeTimezone conversions"
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
This reverts commit a1d6204373.
2025-11-10 17:26:06 -08:00
Francis Cao
a1d6204373 add canonicalizeTimezone conversions
Co-authored-by: Om Mishra <contact@om-mishra.com>
2025-11-10 17:24:51 -08:00
Francis Cao
49e1582c28 implement generateTimeSeries for eventsChart 2025-11-10 15:36:43 -08:00
Francis Cao
64a6379c3c fix realtime logs for mobile
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-10 01:07:11 -08:00
Francis Cao
f3e246c64b fix hasdata queries, add hasData to website events, fix sessionactivity truncation, 2025-11-09 23:58:20 -08:00
Francis Cao
9230f3cb7b manually include basePath 2025-11-09 22:03:06 -08:00
Francis Cao
f30724629c Fix null and string return types from getWebsiteStats 2025-11-09 21:37:35 -08:00
Francis Cao
c44f6f8c9c Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-11-09 21:19:46 -08:00
Francis Cao
bf548c5aca Fix revenue bigInt but and case insensitive currency 2025-11-09 21:19:38 -08:00
Mike Cao
227201a73c
Merge pull request #3706 from metaloozee/3703
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
fix: Redirect loop on auth failure
2025-11-08 11:32:02 -08:00
metaloozee
1879c161ee fix: Redirect loop on auth failure 2025-11-09 00:22:06 +05:30
rkoh-rq
3cb7fa34b0
fix: quote "event" reserved keyword in journey queries
Fixes PostgreSQL syntax error by quoting the "event" column alias. This was causing the journey query to fail.

"event" is a reserved keyword in PostgreSQL. Added double quotes to treat it as an identifier rather than a keyword.

Changes:
- Quote "event" in PostgreSQL
- Quote "event" in ClickHouse for consistency
2025-11-04 11:00:33 +08:00
59 changed files with 845 additions and 530 deletions

View file

@ -6,7 +6,6 @@ services:
- "3000:3000" - "3000:3000"
environment: environment:
DATABASE_URL: postgresql://umami:umami@db:5432/umami DATABASE_URL: postgresql://umami:umami@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: replace-me-with-a-random-string APP_SECRET: replace-me-with-a-random-string
depends_on: depends_on:
db: db:

View file

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "3.0.0", "version": "3.0.1",
"description": "A modern, privacy-focused alternative to Google Analytics.", "description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",
@ -78,7 +78,7 @@
"@react-spring/web": "^10.0.3", "@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"@umami/react-zen": "^0.203.0", "@umami/react-zen": "^0.207.0",
"@umami/redis-client": "^0.29.0", "@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",

259
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.90.5 specifier: ^5.90.5
version: 5.90.5(react@19.2.0) version: 5.90.5(react@19.2.0)
'@umami/react-zen': '@umami/react-zen':
specifier: ^0.203.0 specifier: ^0.207.0
version: 0.203.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) version: 0.207.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
'@umami/redis-client': '@umami/redis-client':
specifier: ^0.29.0 specifier: ^0.29.0
version: 0.29.0 version: 0.29.0
@ -878,6 +878,9 @@ packages:
'@emnapi/runtime@1.5.0': '@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
'@emnapi/runtime@1.7.0':
resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==}
'@emnapi/wasi-threads@1.0.4': '@emnapi/wasi-threads@1.0.4':
resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==}
@ -1163,8 +1166,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-arm64@0.34.4': '@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
@ -1175,8 +1178,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-x64@0.34.4': '@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
@ -1186,8 +1189,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.3': '@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
@ -1196,8 +1199,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.3': '@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
@ -1206,8 +1209,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-arm64@1.2.3': '@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -1216,8 +1219,8 @@ packages:
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-arm@1.2.3': '@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
@ -1226,18 +1229,23 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.3': '@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.0': '@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.3': '@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
@ -1246,8 +1254,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-x64@1.2.3': '@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -1256,8 +1264,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3': '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -1266,8 +1274,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.3': '@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -1277,8 +1285,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-linux-arm64@0.34.4': '@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -1289,8 +1297,8 @@ packages:
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@img/sharp-linux-arm@0.34.4': '@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
@ -1301,20 +1309,26 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-linux-ppc64@0.34.4': '@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.34.3': '@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@img/sharp-linux-s390x@0.34.4': '@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
@ -1325,8 +1339,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-linux-x64@0.34.4': '@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -1337,8 +1351,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.4': '@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -1349,8 +1363,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-linuxmusl-x64@0.34.4': '@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -1360,8 +1374,8 @@ packages:
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32] cpu: [wasm32]
'@img/sharp-wasm32@0.34.4': '@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32] cpu: [wasm32]
@ -1371,8 +1385,8 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@img/sharp-win32-arm64@0.34.4': '@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
@ -1383,8 +1397,8 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@img/sharp-win32-ia32@0.34.4': '@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
@ -1395,8 +1409,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@img/sharp-win32-x64@0.34.4': '@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -2921,8 +2935,8 @@ packages:
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.203.0': '@umami/react-zen@0.207.0':
resolution: {integrity: sha512-lgGUapA0zDbLu63GINaEPndIsT8ry85vE316AWU/EEH3qYDBNscetcBfZFr+DTD/c5eLKy9OxmMwIpbs7k+/UA==} resolution: {integrity: sha512-TUllF5mKQ+IBepIgT0xvJo/bGBEdPDfGiATyjuuxe2/SwO3LDINbmPUSFpWrjxtUaigiEI0JaQsklSLkirEKPg==}
'@umami/redis-client@0.29.0': '@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==} resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -3393,8 +3407,8 @@ packages:
caniuse-lite@1.0.30001741: caniuse-lite@1.0.30001741:
resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==}
caniuse-lite@1.0.30001751: caniuse-lite@1.0.30001754:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==}
caseless@0.12.0: caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@ -6540,8 +6554,8 @@ packages:
peerDependencies: peerDependencies:
react: '>=16.13.1' react: '>=16.13.1'
react-hook-form@7.65.0: react-hook-form@7.66.0:
resolution: {integrity: sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==} resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
@ -6846,8 +6860,8 @@ packages:
resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.34.4: sharp@0.34.5:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@1.2.0: shebang-command@1.2.0:
@ -8184,6 +8198,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.7.0':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.0.4': '@emnapi/wasi-threads@1.0.4':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -8443,9 +8462,9 @@ snapshots:
'@img/sharp-libvips-darwin-arm64': 1.2.0 '@img/sharp-libvips-darwin-arm64': 1.2.0
optional: true optional: true
'@img/sharp-darwin-arm64@0.34.4': '@img/sharp-darwin-arm64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.3 '@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true optional: true
'@img/sharp-darwin-x64@0.34.3': '@img/sharp-darwin-x64@0.34.3':
@ -8453,63 +8472,66 @@ snapshots:
'@img/sharp-libvips-darwin-x64': 1.2.0 '@img/sharp-libvips-darwin-x64': 1.2.0
optional: true optional: true
'@img/sharp-darwin-x64@0.34.4': '@img/sharp-darwin-x64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.3 '@img/sharp-libvips-darwin-x64': 1.2.4
optional: true optional: true
'@img/sharp-libvips-darwin-arm64@1.2.0': '@img/sharp-libvips-darwin-arm64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-darwin-arm64@1.2.3': '@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true optional: true
'@img/sharp-libvips-darwin-x64@1.2.0': '@img/sharp-libvips-darwin-x64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-darwin-x64@1.2.3': '@img/sharp-libvips-darwin-x64@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linux-arm64@1.2.0': '@img/sharp-libvips-linux-arm64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linux-arm64@1.2.3': '@img/sharp-libvips-linux-arm64@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linux-arm@1.2.0': '@img/sharp-libvips-linux-arm@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linux-arm@1.2.3': '@img/sharp-libvips-linux-arm@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linux-ppc64@1.2.0': '@img/sharp-libvips-linux-ppc64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linux-ppc64@1.2.3': '@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linux-s390x@1.2.0': '@img/sharp-libvips-linux-s390x@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linux-s390x@1.2.3': '@img/sharp-libvips-linux-s390x@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linux-x64@1.2.0': '@img/sharp-libvips-linux-x64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linux-x64@1.2.3': '@img/sharp-libvips-linux-x64@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.0': '@img/sharp-libvips-linuxmusl-arm64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.3': '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.0': '@img/sharp-libvips-linuxmusl-x64@1.2.0':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.3': '@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true optional: true
'@img/sharp-linux-arm64@0.34.3': '@img/sharp-linux-arm64@0.34.3':
@ -8517,9 +8539,9 @@ snapshots:
'@img/sharp-libvips-linux-arm64': 1.2.0 '@img/sharp-libvips-linux-arm64': 1.2.0
optional: true optional: true
'@img/sharp-linux-arm64@0.34.4': '@img/sharp-linux-arm64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.3 '@img/sharp-libvips-linux-arm64': 1.2.4
optional: true optional: true
'@img/sharp-linux-arm@0.34.3': '@img/sharp-linux-arm@0.34.3':
@ -8527,9 +8549,9 @@ snapshots:
'@img/sharp-libvips-linux-arm': 1.2.0 '@img/sharp-libvips-linux-arm': 1.2.0
optional: true optional: true
'@img/sharp-linux-arm@0.34.4': '@img/sharp-linux-arm@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.3 '@img/sharp-libvips-linux-arm': 1.2.4
optional: true optional: true
'@img/sharp-linux-ppc64@0.34.3': '@img/sharp-linux-ppc64@0.34.3':
@ -8537,9 +8559,14 @@ snapshots:
'@img/sharp-libvips-linux-ppc64': 1.2.0 '@img/sharp-libvips-linux-ppc64': 1.2.0
optional: true optional: true
'@img/sharp-linux-ppc64@0.34.4': '@img/sharp-linux-ppc64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.3 '@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true optional: true
'@img/sharp-linux-s390x@0.34.3': '@img/sharp-linux-s390x@0.34.3':
@ -8547,9 +8574,9 @@ snapshots:
'@img/sharp-libvips-linux-s390x': 1.2.0 '@img/sharp-libvips-linux-s390x': 1.2.0
optional: true optional: true
'@img/sharp-linux-s390x@0.34.4': '@img/sharp-linux-s390x@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.3 '@img/sharp-libvips-linux-s390x': 1.2.4
optional: true optional: true
'@img/sharp-linux-x64@0.34.3': '@img/sharp-linux-x64@0.34.3':
@ -8557,9 +8584,9 @@ snapshots:
'@img/sharp-libvips-linux-x64': 1.2.0 '@img/sharp-libvips-linux-x64': 1.2.0
optional: true optional: true
'@img/sharp-linux-x64@0.34.4': '@img/sharp-linux-x64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.3 '@img/sharp-libvips-linux-x64': 1.2.4
optional: true optional: true
'@img/sharp-linuxmusl-arm64@0.34.3': '@img/sharp-linuxmusl-arm64@0.34.3':
@ -8567,9 +8594,9 @@ snapshots:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.0 '@img/sharp-libvips-linuxmusl-arm64': 1.2.0
optional: true optional: true
'@img/sharp-linuxmusl-arm64@0.34.4': '@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3 '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true optional: true
'@img/sharp-linuxmusl-x64@0.34.3': '@img/sharp-linuxmusl-x64@0.34.3':
@ -8577,9 +8604,9 @@ snapshots:
'@img/sharp-libvips-linuxmusl-x64': 1.2.0 '@img/sharp-libvips-linuxmusl-x64': 1.2.0
optional: true optional: true
'@img/sharp-linuxmusl-x64@0.34.4': '@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.3 '@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true optional: true
'@img/sharp-wasm32@0.34.3': '@img/sharp-wasm32@0.34.3':
@ -8587,27 +8614,27 @@ snapshots:
'@emnapi/runtime': 1.5.0 '@emnapi/runtime': 1.5.0
optional: true optional: true
'@img/sharp-wasm32@0.34.4': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.5.0 '@emnapi/runtime': 1.7.0
optional: true optional: true
'@img/sharp-win32-arm64@0.34.3': '@img/sharp-win32-arm64@0.34.3':
optional: true optional: true
'@img/sharp-win32-arm64@0.34.4': '@img/sharp-win32-arm64@0.34.5':
optional: true optional: true
'@img/sharp-win32-ia32@0.34.3': '@img/sharp-win32-ia32@0.34.3':
optional: true optional: true
'@img/sharp-win32-ia32@0.34.4': '@img/sharp-win32-ia32@0.34.5':
optional: true optional: true
'@img/sharp-win32-x64@0.34.3': '@img/sharp-win32-x64@0.34.3':
optional: true optional: true
'@img/sharp-win32-x64@0.34.4': '@img/sharp-win32-x64@0.34.5':
optional: true optional: true
'@internationalized/date@3.10.0': '@internationalized/date@3.10.0':
@ -10670,7 +10697,7 @@ snapshots:
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.2
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@umami/react-zen@0.203.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))': '@umami/react-zen@0.207.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
dependencies: dependencies:
'@fontsource/jetbrains-mono': 5.2.8 '@fontsource/jetbrains-mono': 5.2.8
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0
@ -10684,7 +10711,7 @@ snapshots:
react: 19.2.0 react: 19.2.0
react-aria-components: 1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-aria-components: 1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
react-hook-form: 7.65.0(react@19.2.0) react-hook-form: 7.66.0(react@19.2.0)
react-icons: 5.5.0(react@19.2.0) react-icons: 5.5.0(react@19.2.0)
thenby: 1.3.4 thenby: 1.3.4
zustand: 5.0.8(@types/react@19.2.2)(immer@10.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) zustand: 5.0.8(@types/react@19.2.2)(immer@10.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
@ -11215,7 +11242,7 @@ snapshots:
caniuse-lite@1.0.30001741: {} caniuse-lite@1.0.30001741: {}
caniuse-lite@1.0.30001751: {} caniuse-lite@1.0.30001754: {}
caseless@0.12.0: {} caseless@0.12.0: {}
@ -13902,7 +13929,7 @@ snapshots:
dependencies: dependencies:
'@next/env': 15.5.6 '@next/env': 15.5.6
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001751 caniuse-lite: 1.0.30001754
postcss: 8.4.31 postcss: 8.4.31
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
@ -13917,7 +13944,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 15.5.6 '@next/swc-win32-arm64-msvc': 15.5.6
'@next/swc-win32-x64-msvc': 15.5.6 '@next/swc-win32-x64-msvc': 15.5.6
babel-plugin-react-compiler: 19.1.0-rc.2 babel-plugin-react-compiler: 19.1.0-rc.2
sharp: 0.34.4 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
@ -14839,7 +14866,7 @@ snapshots:
'@babel/runtime': 7.28.3 '@babel/runtime': 7.28.3
react: 19.2.0 react: 19.2.0
react-hook-form@7.65.0(react@19.2.0): react-hook-form@7.66.0(react@19.2.0):
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
@ -15266,34 +15293,36 @@ snapshots:
'@img/sharp-win32-x64': 0.34.3 '@img/sharp-win32-x64': 0.34.3
optional: true optional: true
sharp@0.34.4: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.0.0 '@img/colour': 1.0.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.7.3 semver: 7.7.3
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.4 '@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.4 '@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.3 '@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.3 '@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.3 '@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.3 '@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.3 '@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.3 '@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.3 '@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3 '@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.3 '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-linux-arm': 0.34.4 '@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm64': 0.34.4 '@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-ppc64': 0.34.4 '@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-s390x': 0.34.4 '@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-x64': 0.34.4 '@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.4 '@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.4 '@img/sharp-linux-x64': 0.34.5
'@img/sharp-wasm32': 0.34.4 '@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-win32-arm64': 0.34.4 '@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-win32-ia32': 0.34.4 '@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-x64': 0.34.4 '@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true optional: true
shebang-command@1.2.0: shebang-command@1.2.0:

View file

@ -13,12 +13,18 @@ if (process.env.VERCEL && !process.env.BUILD_GEO) {
const db = 'GeoLite2-City'; const db = 'GeoLite2-City';
let url = `https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/master/redist/${db}.tar.gz`; // Support custom URL via environment variable
let url = process.env.GEO_DATABASE_URL;
if (process.env.MAXMIND_LICENSE_KEY) { // Fallback to default URLs if not provided
url = if (!url) {
`https://download.maxmind.com/app/geoip_download` + if (process.env.MAXMIND_LICENSE_KEY) {
`?edition_id=${db}&license_key=${process.env.MAXMIND_LICENSE_KEY}&suffix=tar.gz`; url =
`https://download.maxmind.com/app/geoip_download` +
`?edition_id=${db}&license_key=${process.env.MAXMIND_LICENSE_KEY}&suffix=tar.gz`;
} else {
url = `https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/master/redist/${db}.tar.gz`;
}
} }
const dest = path.resolve(process.cwd(), 'geo'); const dest = path.resolve(process.cwd(), 'geo');
@ -27,30 +33,72 @@ if (!fs.existsSync(dest)) {
fs.mkdirSync(dest); fs.mkdirSync(dest);
} }
const download = url => // Check if URL points to a direct .mmdb file (already extracted)
const isDirectMmdb = url.endsWith('.mmdb');
// Download handler for compressed tar.gz files
const downloadCompressed = url =>
new Promise(resolve => { new Promise(resolve => {
https.get(url, res => { https.get(url, res => {
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t())); resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t()));
}); });
}); });
download(url).then( // Download handler for direct .mmdb files
res => const downloadDirect = (url, originalUrl) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
res.on('entry', entry => { https.get(url, res => {
if (entry.path.endsWith('.mmdb')) { // Follow redirects
const filename = path.join(dest, path.basename(entry.path)); if (res.statusCode === 301 || res.statusCode === 302) {
entry.pipe(fs.createWriteStream(filename)); downloadDirect(res.headers.location, originalUrl || url).then(resolve).catch(reject);
return;
console.log('Saved geo database:', filename); }
}
}); const filename = path.join(dest, path.basename(originalUrl || url));
const fileStream = fs.createWriteStream(filename);
res.on('error', e => {
reject(e); res.pipe(fileStream);
});
res.on('finish', () => { fileStream.on('finish', () => {
fileStream.close();
console.log('Saved geo database:', filename);
resolve(); resolve();
}); });
}),
); fileStream.on('error', e => {
reject(e);
});
});
});
// Execute download based on file type
if (isDirectMmdb) {
downloadDirect(url).catch(e => {
console.error('Failed to download geo database:', e);
process.exit(1);
});
} else {
downloadCompressed(url).then(
res =>
new Promise((resolve, reject) => {
res.on('entry', entry => {
if (entry.path.endsWith('.mmdb')) {
const filename = path.join(dest, path.basename(entry.path));
entry.pipe(fs.createWriteStream(filename));
console.log('Saved geo database:', filename);
}
});
res.on('error', e => {
reject(e);
});
res.on('finish', () => {
resolve();
});
}),
).catch(e => {
console.error('Failed to download geo database:', e);
process.exit(1);
});
}

View file

@ -36,6 +36,10 @@ async function checkEnv() {
} else { } else {
success('DATABASE_URL is defined.'); success('DATABASE_URL is defined.');
} }
if (process.env.REDIS_URL) {
success('REDIS_URL is defined.');
}
} }
async function checkConnection() { async function checkConnection() {

View file

@ -5,22 +5,29 @@ import { UpdateNotice } from './UpdateNotice';
import { SideNav } from '@/app/(main)/SideNav'; import { SideNav } from '@/app/(main)/SideNav';
import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks'; import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks';
import { MobileNav } from '@/app/(main)/MobileNav'; import { MobileNav } from '@/app/(main)/MobileNav';
import { useEffect } from 'react';
import { removeItem, setItem } from '@/lib/storage';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
export function App({ children }) { export function App({ children }) {
const { user, isLoading, error } = useLoginQuery(); const { user, isLoading, error } = useLoginQuery();
const config = useConfig(); const config = useConfig();
const { pathname, router } = useNavigation(); const { pathname, teamId } = useNavigation();
useEffect(() => {
if (teamId) {
setItem(LAST_TEAM_CONFIG, teamId);
} else {
removeItem(LAST_TEAM_CONFIG);
}
}, [teamId]);
if (isLoading || !config) { if (isLoading || !config) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
if (error) { if (error) {
if (process.env.cloudMode) { window.location.href = `${process.env.basePath || ''}/login`;
window.location.href = '/login';
} else {
router.push('/login');
}
return null; return null;
} }

View file

@ -95,7 +95,7 @@ export function Attribution({
})} })}
</MetricsBar> </MetricsBar>
<SectionHeader title={formatMessage(labels.sources)} /> <SectionHeader title={formatMessage(labels.sources)} />
<Grid columns="1fr 1fr" gap> <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
<Panel> <Panel>
<AttributionTable data={data?.['referrer']} title={formatMessage(labels.referrer)} /> <AttributionTable data={data?.['referrer']} title={formatMessage(labels.referrer)} />
</Panel> </Panel>
@ -104,7 +104,7 @@ export function Attribution({
</Panel> </Panel>
</Grid> </Grid>
<SectionHeader title="UTM" /> <SectionHeader title="UTM" />
<Grid columns="1fr 1fr" gap> <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
<Panel> <Panel>
<AttributionTable data={data?.['utm_source']} title={formatMessage(labels.sources)} /> <AttributionTable data={data?.['utm_source']} title={formatMessage(labels.sources)} />
</Panel> </Panel>

View file

@ -17,7 +17,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap="6"> <Column gap="6">
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<Grid columns="1fr 1fr 1fr" gap> <Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap>
<Column> <Column>
<Select <Select
label={formatMessage(labels.model)} label={formatMessage(labels.model)}

View file

@ -1,4 +1,4 @@
import { Text, DataTable, DataColumn } from '@umami/react-zen'; import { Text, DataTable, DataColumn, Column } from '@umami/react-zen';
import { useMessages, useResultQuery, useFormat, useFields } from '@/components/hooks'; import { useMessages, useResultQuery, useFormat, useFields } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { formatShortTime } from '@/lib/format'; import { formatShortTime } from '@/lib/format';
@ -27,43 +27,65 @@ export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }
return ( return (
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
<DataTable data={data}> <Column overflow="auto" minHeight="0" height="100%">
{selectedFields.map(field => { <DataTable data={data} style={{ tableLayout: 'fixed' }}>
return ( {selectedFields.map(field => {
<DataColumn key={field} id={field} label={fields.find(f => f.name === field)?.label}> return (
{row => { <DataColumn
const value = formatValue(row[field], field); key={field}
return ( id={field}
<Text truncate title={value}> label={fields.find(f => f.name === field)?.label}
{value} width="minmax(120px, 1fr)"
</Text> >
); {row => {
}} const value = formatValue(row[field], field);
</DataColumn> return (
); <Text truncate title={value}>
})} {value}
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end"> </Text>
{row => row?.['visitors']?.toLocaleString()} );
</DataColumn> }}
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end"> </DataColumn>
{row => row?.['visits']?.toLocaleString()} );
</DataColumn> })}
<DataColumn id="views" label={formatMessage(labels.views)} align="end"> <DataColumn
{row => row?.['views']?.toLocaleString()} id="visitors"
</DataColumn> label={formatMessage(labels.visitors)}
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end"> align="end"
{row => { width="120px"
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100; >
return Math.round(+n) + '%'; {row => row?.['visitors']?.toLocaleString()}
}} </DataColumn>
</DataColumn> <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px">
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end"> {row => row?.['visits']?.toLocaleString()}
{row => { </DataColumn>
const n = row?.['totaltime'] / row?.['visits']; <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px">
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; {row => row?.['views']?.toLocaleString()}
}} </DataColumn>
</DataColumn> <DataColumn
</DataTable> id="bounceRate"
label={formatMessage(labels.bounceRate)}
align="end"
width="120px"
>
{row => {
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
return Math.round(+n) + '%';
}}
</DataColumn>
<DataColumn
id="visitDuration"
label={formatMessage(labels.visitDuration)}
align="end"
width="120px"
>
{row => {
const n = row?.['totaltime'] / row?.['visits'];
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
}}
</DataColumn>
</DataTable>
</Column>
</LoadingPanel> </LoadingPanel>
); );
} }

View file

@ -2,7 +2,7 @@
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm'; import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages, useMobile } from '@/components/hooks'; import { useDateRange, useMessages } from '@/components/hooks';
import { ListCheck } from '@/components/icons'; import { ListCheck } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
import { Column, Row } from '@umami/react-zen'; import { Column, Row } from '@umami/react-zen';
@ -14,12 +14,10 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const [fields, setFields] = useState(['path']); const [fields, setFields] = useState(['path']);
const { isMobile } = useMobile();
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<Row alignItems="center" justifyContent={isMobile ? 'flex-end' : 'flex-start'}> <Row alignItems="center" justifyContent="flex-start">
<FieldsButton value={fields} onChange={setFields} /> <FieldsButton value={fields} onChange={setFields} />
</Row> </Row>
<Panel height="900px" overflow="auto" allowFullscreen> <Panel height="900px" overflow="auto" allowFullscreen>
@ -41,8 +39,9 @@ const FieldsButton = ({ value, onChange }) => {
<DialogButton <DialogButton
icon={<ListCheck />} icon={<ListCheck />}
label={formatMessage(labels.fields)} label={formatMessage(labels.fields)}
width="800px" width="400px"
minHeight="300px" minHeight="300px"
variant="outline"
> >
{({ close }) => { {({ close }) => {
return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />; return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />;

View file

@ -56,7 +56,7 @@ export function FunnelEditForm({
const defaultValues = { const defaultValues = {
name: data?.name || '', name: data?.name || '',
window: data?.parameters?.window || 60, window: data?.parameters?.window || 60,
steps: data?.parameters?.steps || [{ type: 'path', value: '/' }], steps: data?.parameters?.steps || [{ type: 'path', value: '' }],
}; };
return ( return (
@ -82,12 +82,10 @@ export function FunnelEditForm({
validate: value => value.length > 1 || 'At least two steps are required', validate: value => value.length > 1 || 'At least two steps are required',
}} }}
> >
{({ fields, append, remove, getValues }) => { {({ fields, append, remove }) => {
return ( return (
<Grid gap> <Grid gap>
{fields.map(({ id }: { id: string }, index: number) => { {fields.map(({ id }: { id: string }, index: number) => {
const type = getValues(`steps.${index}.type`);
return ( return (
<Grid key={id} columns="260px 1fr auto" gap> <Grid key={id} columns="260px 1fr auto" gap>
<Column> <Column>
@ -103,7 +101,8 @@ export function FunnelEditForm({
name={`steps.${index}.value`} name={`steps.${index}.value`}
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
{({ field }) => { {({ field, context }) => {
const type = context.watch(`steps.${index}.type`);
return <LookupField websiteId={websiteId} type={type} {...field} />; return <LookupField websiteId={websiteId} type={type} {...field} />;
}} }}
</FormField> </FormField>
@ -118,7 +117,7 @@ export function FunnelEditForm({
})} })}
<Row> <Row>
<Button <Button
onPress={() => append({ type: 'path', value: '/' })} onPress={() => append({ type: 'path', value: '' })}
isDisabled={fields.length >= FUNNEL_STEPS_MAX} isDisabled={fields.length >= FUNNEL_STEPS_MAX}
> >
<Icon> <Icon>

View file

@ -17,9 +17,8 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
<Modal> <Modal>
<Dialog <Dialog
aria-label="add goal" aria-label="add goal"
variant="modal"
title={formatMessage(labels.goal)} title={formatMessage(labels.goal)}
style={{ minWidth: 800, minHeight: 300 }} style={{ minWidth: 400, minHeight: 300 }}
> >
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />} {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
</Dialog> </Dialog>

View file

@ -51,51 +51,72 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Panel allowFullscreen height="900px"> <Panel allowFullscreen height="900px">
<Column gap="1" width="100%" overflow="auto"> <Column
<Grid paddingY="6"
columns="120px repeat(10, 100px)" paddingX={{ xs: '3', md: '6' }}
alignItems="center" position="absolute"
gap="1" top="40px"
height="50px" left="0"
autoFlow="column" right="0"
> bottom="0"
<Column> >
<Text weight="bold" align="center"> <Column gap="1" overflow="auto">
{formatMessage(labels.cohort)} <Grid
</Text> columns="120px repeat(10, 100px)"
</Column> alignItems="center"
{days.map(n => ( gap="1"
<Column key={n}> height="50px"
<Text weight="bold" align="center" wrap="nowrap"> width="max-content"
{formatMessage(labels.day)} {n} minWidth="100%"
autoFlow="column"
>
<Column>
<Text weight="bold" align="center">
{formatMessage(labels.cohort)}
</Text> </Text>
</Column> </Column>
))} {days.map(n => (
</Grid> <Column key={n}>
{rows.map(({ date, visitors, records }: any, rowIndex: number) => { <Text weight="bold" align="center" wrap="nowrap">
return ( {formatMessage(labels.day)} {n}
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column"> </Text>
<Column justifyContent="center" gap="1">
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
<Row alignItems="center" gap>
<Icon>
<Users />
</Icon>
<Text>{formatLongNumber(visitors)}</Text>
</Row>
</Column> </Column>
{days.map(day => { ))}
if (totalDays - rowIndex < day) { </Grid>
return null; {rows.map(({ date, visitors, records }: any, rowIndex: number) => {
} return (
const percentage = records.filter(a => a.day === day)[0]?.percentage; <Grid
return ( key={rowIndex}
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell> columns="120px repeat(10, 100px)"
); gap="1"
})} autoFlow="column"
</Grid> width="max-content"
); minWidth="100%"
})} >
<Column justifyContent="center" gap="1">
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
<Row alignItems="center" gap>
<Icon>
<Users />
</Icon>
<Text>{formatLongNumber(visitors)}</Text>
</Row>
</Column>
{days.map(day => {
if (totalDays - rowIndex < day) {
return null;
}
const percentage = records.filter(a => a.day === day)[0]?.percentage;
return (
<Cell key={day}>
{percentage ? `${Number(percentage).toFixed(2)}%` : ''}
</Cell>
);
})}
</Grid>
);
})}
</Column>
</Column> </Column>
</Panel> </Panel>
)} )}

View file

@ -43,7 +43,7 @@ export function UTM({ websiteId, startDate, endDate }: UTMProps) {
return ( return (
<Panel key={param}> <Panel key={param}>
<Grid columns="1fr 1fr"> <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6">
<Column> <Column>
<Heading> <Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text> <Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>

View file

@ -34,6 +34,7 @@ export function ExpandedViewModal({
maxWidth: 1320, maxWidth: 1320,
width: '100vw', width: '100vw',
height: isMobile ? '100dvh' : 'calc(100dvh - 40px)', height: isMobile ? '100dvh' : 'calc(100dvh - 40px)',
overflow: 'hidden',
}} }}
> >
{({ close }) => { {({ close }) => {

View file

@ -1,5 +1,5 @@
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange } from '@/components/hooks'; import { useDateRange, useTimezone } from '@/components/hooks';
import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
import { PageviewsChart } from '@/components/metrics/PageviewsChart'; import { PageviewsChart } from '@/components/metrics/PageviewsChart';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -11,7 +11,8 @@ export function WebsiteChart({
websiteId: string; websiteId: string;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { dateRange, dateCompare } = useDateRange(); const { timezone } = useTimezone();
const { dateRange, dateCompare } = useDateRange({ timezone: timezone });
const { startDate, endDate, unit, value } = dateRange; const { startDate, endDate, unit, value } = dateRange;
const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
websiteId, websiteId,

View file

@ -23,10 +23,10 @@ export function WebsiteControls({
return ( return (
<Column gap> <Column gap>
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap> <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
<Row alignItems="center" justifyContent="flex-end"> <Row alignItems="center" justifyContent="flex-start">
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />} {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
</Row> </Row>
<Row alignItems="center" justifyContent="flex-end"> <Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}>
{allowDateFilter && ( {allowDateFilter && (
<WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} /> <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
)} )}

View file

@ -19,17 +19,23 @@ export function WebsiteExpandedView({
} = useNavigation(); } = useNavigation();
return ( return (
<Column gap> <Column height="100%" overflow="hidden" gap>
<Row display={{ xs: 'flex', md: 'none' }}> <Row id="expanded-mobile-menu-button" display={{ xs: 'flex', md: 'none' }}>
<MobileMenuButton> <MobileMenuButton>
{({ close }) => { {({ close }) => {
return <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />; return (
<Column padding="3">
<WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />
</Column>
);
}} }}
</MobileMenuButton> </MobileMenuButton>
</Row> </Row>
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" height="100%" overflow="hidden"> <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" overflow="hidden">
<Column <Column
id="metrics-expanded-menu"
display={{ xs: 'none', md: 'flex' }} display={{ xs: 'none', md: 'flex' }}
width="240px"
gap="6" gap="6"
border="right" border="right"
paddingRight="3" paddingRight="3"
@ -37,7 +43,7 @@ export function WebsiteExpandedView({
> >
<WebsiteExpandedMenu excludedIds={excludedIds} /> <WebsiteExpandedMenu excludedIds={excludedIds} />
</Column> </Column>
<Column overflow="hidden"> <Column id="metrics-expanded-table" overflow="hidden">
<MetricsExpandedTable <MetricsExpandedTable
title={formatMessage(labels[view])} title={formatMessage(labels[view])}
type={view} type={view}

View file

@ -19,15 +19,11 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
return ( return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3"> <PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3">
<Row alignItems="center" gap="6"> <Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />
{showActions && ( {showActions && (
<Row <Row alignItems="center" gap>
display={{ xs: 'none', sm: 'none', md: 'none', lg: 'flex', xl: 'flex' }}
alignItems="center"
gap
>
<ShareButton websiteId={website.id} shareId={website.shareId} /> <ShareButton websiteId={website.id} shareId={website.shareId} />
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}> <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
<Icon> <Icon>

View file

@ -1,11 +1,24 @@
import { DataTable, DataColumn, Row, Text, DataTableProps, IconLabel } from '@umami/react-zen'; import {
DataTable,
DataColumn,
Row,
Text,
DataTableProps,
IconLabel,
Button,
Dialog,
DialogTrigger,
Icon,
Popover,
} from '@umami/react-zen';
import { useFormat, useMessages, useNavigation } from '@/components/hooks'; import { useFormat, useMessages, useNavigation } from '@/components/hooks';
import { Avatar } from '@/components/common/Avatar'; import { Avatar } from '@/components/common/Avatar';
import Link from 'next/link'; import Link from 'next/link';
import { Eye } from '@/components/icons'; import { Eye, FileText } from '@/components/icons';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon'; import { TypeIcon } from '@/components/common/TypeIcon';
import { EventData } from '@/components/metrics/EventData';
export function EventsTable(props: DataTableProps) { export function EventsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,6 +45,7 @@ export function EventsTable(props: DataTableProps) {
> >
{row.eventName || row.urlPath} {row.eventName || row.urlPath}
</Text> </Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row> </Row>
); );
}} }}
@ -72,3 +86,22 @@ export function EventsTable(props: DataTableProps) {
</DataTable> </DataTable>
); );
} }
const PropertiesButton = props => {
return (
<DialogTrigger>
<Button variant="quiet">
<Row alignItems="center" gap>
<Icon>
<FileText />
</Icon>
</Row>
</Button>
<Popover placement="right">
<Dialog>
<EventData {...props} />
</Dialog>
</Popover>
</DialogTrigger>
);
};

View file

@ -9,6 +9,7 @@ import {
useCountryNames, useCountryNames,
useLocale, useLocale,
useMessages, useMessages,
useMobile,
useNavigation, useNavigation,
useTimezone, useTimezone,
useWebsite, useWebsite,
@ -40,6 +41,7 @@ export function RealtimeLog({ data }: { data: any }) {
const { countryNames } = useCountryNames(locale); const { countryNames } = useCountryNames(locale);
const [filter, setFilter] = useState(TYPE_ALL); const [filter, setFilter] = useState(TYPE_ALL);
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { isPhone } = useMobile();
const buttons = [ const buttons = [
{ {
@ -123,12 +125,18 @@ export function RealtimeLog({ data }: { data: any }) {
const row = logs[index]; const row = logs[index];
return ( return (
<Row alignItems="center" style={style} gap> <Row alignItems="center" style={style} gap>
<Link href={updateParams({ session: row.sessionId })}> <Row minWidth="30px">
<Avatar seed={row.sessionId} size={32} /> <Link href={updateParams({ session: row.sessionId })}>
</Link> <Avatar seed={row.sessionId} size={32} />
<Row width="100px">{getTime(row)}</Row> </Link>
</Row>
<Row minWidth="100px">
<Text wrap="nowrap">{getTime(row)}</Text>
</Row>
<IconLabel icon={getIcon(row)}> <IconLabel icon={getIcon(row)}>
<Text>{getDetail(row)}</Text> <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate>
{getDetail(row)}
</Text>
</IconLabel> </IconLabel>
</Row> </Row>
); );
@ -168,10 +176,22 @@ export function RealtimeLog({ data }: { data: any }) {
return ( return (
<Column gap> <Column gap>
<Heading size="2">{formatMessage(labels.activity)}</Heading> <Heading size="2">{formatMessage(labels.activity)}</Heading>
<Row alignItems="center" justifyContent="space-between"> {isPhone ? (
<SearchField value={search} onSearch={setSearch} /> <>
<FilterButtons items={buttons} value={filter} onChange={setFilter} /> <Row>
</Row> <SearchField value={search} onSearch={setSearch} />
</Row>
<Row>
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
</Row>
</>
) : (
<Row alignItems="center" justifyContent="space-between">
<SearchField value={search} onSearch={setSearch} />
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
</Row>
)}
<Column> <Column>
{logs?.length === 0 && <Empty />} {logs?.length === 0 && <Empty />}
{logs?.length > 0 && ( {logs?.length > 0 && (

View file

@ -6,7 +6,7 @@ import { PageBody } from '@/components/common/PageBody';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { RealtimeChart } from '@/components/metrics/RealtimeChart'; import { RealtimeChart } from '@/components/metrics/RealtimeChart';
import { WorldMap } from '@/components/metrics/WorldMap'; import { WorldMap } from '@/components/metrics/WorldMap';
import { useRealtimeQuery } from '@/components/hooks'; import { useMobile, useRealtimeQuery } from '@/components/hooks';
import { RealtimeLog } from './RealtimeLog'; import { RealtimeLog } from './RealtimeLog';
import { RealtimeHeader } from './RealtimeHeader'; import { RealtimeHeader } from './RealtimeHeader';
import { RealtimePaths } from './RealtimePaths'; import { RealtimePaths } from './RealtimePaths';
@ -16,6 +16,7 @@ import { percentFilter } from '@/lib/filters';
export function RealtimePage({ websiteId }: { websiteId: string }) { export function RealtimePage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useRealtimeQuery(websiteId); const { data, isLoading, error } = useRealtimeQuery(websiteId);
const { isMobile } = useMobile();
if (isLoading || error) { if (isLoading || error) {
return <PageBody isLoading={isLoading} error={error} />; return <PageBody isLoading={isLoading} error={error} />;
@ -48,7 +49,7 @@ export function RealtimePage({ websiteId }: { websiteId: string }) {
<Panel> <Panel>
<RealtimeCountries data={countries} /> <RealtimeCountries data={countries} />
</Panel> </Panel>
<Panel gridColumn="span 2" padding="0"> <Panel gridColumn={isMobile ? null : 'span 2'} padding="0">
<WorldMap data={countries} /> <WorldMap data={countries} />
</Panel> </Panel>
</GridRow> </GridRow>

View file

@ -14,7 +14,7 @@ import {
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Eye, FileText } from '@/components/icons'; import { Eye, FileText } from '@/components/icons';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
import { useMessages, useSessionActivityQuery, useTimezone } from '@/components/hooks'; import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks';
import { EventData } from '@/components/metrics/EventData'; import { EventData } from '@/components/metrics/EventData';
export function SessionActivity({ export function SessionActivity({
@ -36,6 +36,7 @@ export function SessionActivity({
startDate, startDate,
endDate, endDate,
); );
const { isMobile } = useMobile();
let lastDay = null; let lastDay = null;
return ( return (
@ -50,16 +51,16 @@ export function SessionActivity({
{showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>} {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
<Row alignItems="center" gap="6" height="40px"> <Row alignItems="center" gap="6" height="40px">
<StatusLight color={`#${visitId?.substring(0, 6)}`}> <StatusLight color={`#${visitId?.substring(0, 6)}`}>
{formatTimezoneDate(createdAt, 'pp')} <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text>
</StatusLight> </StatusLight>
<Row alignItems="center" gap="2"> <Row alignItems="center" gap="2">
<Icon>{eventName ? <Lightning /> : <Eye />}</Icon> <Icon>{eventName ? <Lightning /> : <Eye />}</Icon>
<Text> <Text wrap="nowrap">
{eventName {eventName
? formatMessage(labels.triggeredEvent) ? formatMessage(labels.triggeredEvent)
: formatMessage(labels.viewedPage)} : formatMessage(labels.viewedPage)}
</Text> </Text>
<Text weight="bold" style={{ maxWidth: '400px' }} truncate> <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath} {eventName || urlPath}
</Text> </Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}

View file

@ -1,21 +1,15 @@
import { REALTIME_RANGE } from '@/lib/constants'; import { REALTIME_RANGE } from '@/lib/constants';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { timezoneParam } from '@/lib/schema';
import { canViewWebsite } from '@/permissions'; import { canViewWebsite } from '@/permissions';
import { getRealtimeData } from '@/queries/sql'; import { getRealtimeData } from '@/queries/sql';
import { startOfMinute, subMinutes } from 'date-fns'; import { startOfMinute, subMinutes } from 'date-fns';
import z from 'zod';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: Promise<{ websiteId: string }> }, { params }: { params: Promise<{ websiteId: string }> },
) { ) {
const schema = z.object({ const { auth, query, error } = await parseRequest(request);
timezone: timezoneParam,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) { if (error) {
return error(); return error();

View file

@ -146,6 +146,7 @@ export async function POST(request: Request) {
region, region,
city, city,
distinctId: id, distinctId: id,
createdAt,
}); });
} }

View file

@ -44,15 +44,16 @@ export function LoginForm() {
name="username" name="username"
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<TextField autoComplete="off" /> <TextField autoComplete="username" />
</FormField> </FormField>
<FormField <FormField
label={formatMessage(labels.password)} label={formatMessage(labels.password)}
data-test="input-password" data-test="input-password"
name="password" name="password"
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<PasswordField /> <PasswordField autoComplete="current-password" />
</FormField> </FormField>
<FormButtons> <FormButtons>
<FormSubmitButton <FormSubmitButton

View file

@ -13,7 +13,7 @@ export function LogoutPage() {
async function logout() { async function logout() {
await post('/auth/logout'); await post('/auth/logout');
router.push('/login'); window.location.href = `${process.env.basePath || ''}/login`;
} }
removeClientAuthToken(); removeClientAuthToken();

View file

@ -1,5 +1,21 @@
'use client';
import { useEffect } from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getItem, removeItem } from '@/lib/storage';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
export default function RootPage() { export default function RootPage() {
redirect('/websites'); useEffect(() => {
const lastTeam = getItem(LAST_TEAM_CONFIG);
if (lastTeam) {
redirect(`/teams/${lastTeam}/websites`);
} else {
removeItem(LAST_TEAM_CONFIG);
redirect(`/websites`);
}
}, []);
return null;
} }

View file

@ -1,13 +1,18 @@
import { Text } from '@umami/react-zen'; import { Text } from '@umami/react-zen';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useLocale, useTimezone } from '@/components/hooks'; import { useLocale, useTimezone } from '@/components/hooks';
import { isInvalidDate } from '@/lib/date';
export function DateDistance({ date }: { date: Date }) { export function DateDistance({ date }: { date: Date }) {
const { formatTimezoneDate } = useTimezone(); const { formatTimezoneDate } = useTimezone();
const { dateLocale } = useLocale(); const { dateLocale } = useLocale();
if (!isInvalidDate(date)) {
return null;
}
return ( return (
<Text title={formatTimezoneDate(date.toISOString(), 'PPPpp')}> <Text title={formatTimezoneDate(date?.toISOString(), 'PPPpp')}>
{formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })} {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })}
</Text> </Text>
); );

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Heading, Icon, Row, RowProps, Text, Column } from '@umami/react-zen'; import { Heading, Icon, Row, Text, Column, Grid } from '@umami/react-zen';
export function PageHeader({ export function PageHeader({
title, title,
@ -8,7 +8,6 @@ export function PageHeader({
icon, icon,
showBorder = true, showBorder = true,
children, children,
...props
}: { }: {
title: string; title: string;
description?: string; description?: string;
@ -18,16 +17,13 @@ export function PageHeader({
allowEdit?: boolean; allowEdit?: boolean;
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
} & RowProps) { }) {
return ( return (
<Row <Grid
justifyContent="space-between" columns={{ xs: '1fr', md: '1fr 1fr' }}
alignItems="center"
paddingY="6" paddingY="6"
marginBottom="6" marginBottom="6"
border={showBorder ? 'bottom' : undefined} border={showBorder ? 'bottom' : undefined}
width="100%"
{...props}
> >
<Column gap="2"> <Column gap="2">
{label} {label}
@ -46,6 +42,6 @@ export function PageHeader({
)} )}
</Column> </Column>
<Row justifyContent="flex-end">{children}</Row> <Row justifyContent="flex-end">{children}</Row>
</Row> </Grid>
); );
} }

View file

@ -1,34 +1,13 @@
import { useTimezone } from '@/components/hooks/useTimezone';
import { REALTIME_INTERVAL } from '@/lib/constants'; import { REALTIME_INTERVAL } from '@/lib/constants';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { RealtimeData } from '@/lib/types';
export interface RealtimeData {
countries: Record<string, number>;
events: any[];
pageviews: any[];
referrers: Record<string, number>;
timestamp: number;
series: {
views: any[];
visitors: any[];
};
totals: {
views: number;
visitors: number;
events: number;
countries: number;
};
urls: Record<string, number>;
visitors: any[];
}
export function useRealtimeQuery(websiteId: string) { export function useRealtimeQuery(websiteId: string) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { timezone } = useTimezone();
const { data, isLoading, error } = useQuery<RealtimeData>({ const { data, isLoading, error } = useQuery<RealtimeData>({
queryKey: ['realtime', { websiteId, timezone }], queryKey: ['realtime', { websiteId }],
queryFn: async () => { queryFn: async () => {
return get(`/realtime/${websiteId}`, { timezone }); return get(`/realtime/${websiteId}`);
}, },
enabled: !!websiteId, enabled: !!websiteId,
refetchInterval: REALTIME_INTERVAL, refetchInterval: REALTIME_INTERVAL,

View file

@ -5,14 +5,14 @@ export function useDateParameters() {
const { const {
dateRange: { startDate, endDate, unit }, dateRange: { startDate, endDate, unit },
} = useDateRange(); } = useDateRange();
const { timezone, toUtc } = useTimezone(); const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
return { return {
startAt: +toUtc(startDate), startAt: +localToUtc(startDate),
endAt: +toUtc(endDate), endAt: +localToUtc(endDate),
startDate: toUtc(startDate).toISOString(), startDate: localToUtc(startDate).toISOString(),
endDate: toUtc(endDate).toISOString(), endDate: localToUtc(endDate).toISOString(),
unit, unit,
timezone, timezone: canonicalizeTimezone(timezone),
}; };
} }

View file

@ -5,7 +5,7 @@ import { useLocale } from '@/components/hooks/useLocale';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage'; import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
@ -15,6 +15,7 @@ export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
locale, locale,
options.timezone,
); );
return !options.ignoreOffset && offset return !options.ignoreOffset && offset

View file

@ -1,13 +1,15 @@
import { setItem } from '@/lib/storage'; import { setItem } from '@/lib/storage';
import { TIMEZONE_CONFIG } from '@/lib/constants'; import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants';
import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'; import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
import { useApp, setTimezone } from '@/store/app'; import { useApp, setTimezone } from '@/store/app';
import { useLocale } from './useLocale'; import { useLocale } from './useLocale';
import { getTimezone } from '@/lib/date';
const selector = (state: { timezone: string }) => state.timezone; const selector = (state: { timezone: string }) => state.timezone;
export function useTimezone() { export function useTimezone() {
const timezone = useApp(selector); const timezone = useApp(selector);
const localTimeZone = getTimezone();
const { dateLocale } = useLocale(); const { dateLocale } = useLocale();
const saveTimezone = (value: string) => { const saveTimezone = (value: string) => {
@ -26,6 +28,38 @@ export function useTimezone() {
); );
}; };
const formatSeriesTimezone = (data: any, column: string, timezone: string) => {
return data.map(item => {
const date = new Date(item[column]);
const format = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = format.formatToParts(date);
const get = type => parts.find(p => p.type === type)?.value;
const year = get('year');
const month = get('month');
const day = get('day');
const hour = get('hour');
const minute = get('minute');
const second = get('second');
return {
...item,
[column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`,
};
});
};
const toUtc = (date: Date | string | number) => { const toUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, timezone); return zonedTimeToUtc(date, timezone);
}; };
@ -34,5 +68,28 @@ export function useTimezone() {
return utcToZonedTime(date, timezone); return utcToZonedTime(date, timezone);
}; };
return { timezone, saveTimezone, formatTimezoneDate, toUtc, fromUtc }; const localToUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, localTimeZone);
};
const localFromUtc = (date: Date | string | number) => {
return utcToZonedTime(date, localTimeZone);
};
const canonicalizeTimezone = (timezone: string): string => {
return TIMEZONE_LEGACY[timezone] ?? timezone;
};
return {
timezone,
localTimeZone,
toUtc,
fromUtc,
localToUtc,
localFromUtc,
saveTimezone,
formatTimezoneDate,
formatSeriesTimezone,
canonicalizeTimezone,
};
} }

View file

@ -28,6 +28,7 @@ export function WebsiteDateFilter({
query: { compare = 'prev', offset = 0 }, query: { compare = 'prev', offset = 0 },
} = useNavigation(); } = useNavigation();
const disableForward = isAllTime || isAfter(dateRange.endDate, new Date()); const disableForward = isAllTime || isAfter(dateRange.endDate, new Date());
const showCompare = allowCompare && !isAllTime;
const websiteDateRange = useDateRangeQuery(websiteId); const websiteDateRange = useDateRangeQuery(websiteId);
@ -62,7 +63,7 @@ export function WebsiteDateFilter({
}, [dateRange]); }, [dateRange]);
return ( return (
<Row gap> <Row wrap="wrap" gap>
{showButtons && !isAllTime && !isCustomRange && ( {showButtons && !isAllTime && !isCustomRange && (
<Row gap="1"> <Row gap="1">
<Button onPress={() => handleIncrement(-1)} variant="outline"> <Button onPress={() => handleIncrement(-1)} variant="outline">
@ -85,7 +86,7 @@ export function WebsiteDateFilter({
renderDate={+offset !== 0} renderDate={+offset !== 0}
/> />
</Row> </Row>
{allowCompare && !isAllTime && ( {showCompare && (
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Text weight="bold">VS</Text> <Text weight="bold">VS</Text>
<Row width="200px"> <Row width="200px">

View file

@ -1,10 +1,16 @@
import { useMemo, useState, useEffect } from 'react';
import { colord } from 'colord';
import { BarChart, BarChartProps } from '@/components/charts/BarChart'; import { BarChart, BarChartProps } from '@/components/charts/BarChart';
import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import {
useDateRange,
useLocale,
useTimezone,
useWebsiteEventsSeriesQuery,
} from '@/components/hooks';
import { renderDateLabels } from '@/lib/charts'; import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS } from '@/lib/constants';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { generateTimeSeries } from '@/lib/date';
import { colord } from 'colord';
import { useCallback, useEffect, useMemo, useState } from 'react';
export interface EventsChartProps extends BarChartProps { export interface EventsChartProps extends BarChartProps {
websiteId: string; websiteId: string;
@ -12,10 +18,11 @@ export interface EventsChartProps extends BarChartProps {
} }
export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
const { timezone } = useTimezone();
const { const {
dateRange: { startDate, endDate, unit }, dateRange: { startDate, endDate, unit },
} = useDateRange(); } = useDateRange({ timezone: timezone });
const { locale } = useLocale(); const { locale, dateLocale } = useLocale();
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId); const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
const [label, setLabel] = useState<string>(focusLabel); const [label, setLabel] = useState<string>(focusLabel);
@ -32,20 +39,32 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
return obj; return obj;
}, {}); }, {});
return { if (!map || Object.keys(map).length === 0) {
datasets: Object.keys(map).map((key, index) => { return {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]); datasets: [
return { {
label: key, data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
data: map[key], lineTension: 0,
lineTension: 0, borderWidth: 1,
backgroundColor: color.alpha(0.6).toRgbString(), },
borderColor: color.alpha(0.7).toRgbString(), ],
borderWidth: 1, };
}; } else {
}), return {
focusLabel, datasets: Object.keys(map).map((key, index) => {
}; const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
focusLabel,
};
}
}, [data, startDate, endDate, unit, focusLabel]); }, [data, startDate, endDate, unit, focusLabel]);
useEffect(() => { useEffect(() => {
@ -54,6 +73,8 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
} }
}, [focusLabel]); }, [focusLabel]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
return ( return (
<LoadingPanel isLoading={isLoading} error={error} minHeight="400px"> <LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
{chartData && ( {chartData && (
@ -63,7 +84,7 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
maxDate={endDate} maxDate={endDate}
unit={unit} unit={unit}
stacked={true} stacked={true}
renderXLabel={renderDateLabels(unit, locale)} renderXLabel={renderXLabel}
height="400px" height="400px"
/> />
)} )}

View file

@ -57,7 +57,7 @@ export function ListTable({
showPercentage={showPercentage} showPercentage={showPercentage}
change={renderChange ? renderChange(row, index) : null} change={renderChange ? renderChange(row, index) : null}
currency={currency} currency={currency}
isMobile={isPhone} isPhone={isPhone}
/> />
); );
}; };
@ -101,7 +101,7 @@ const AnimatedRow = ({
animate, animate,
showPercentage = true, showPercentage = true,
currency, currency,
isMobile, isPhone,
}) => { }) => {
const props = useSpring({ const props = useSpring({
width: percent, width: percent,
@ -120,7 +120,7 @@ const AnimatedRow = ({
gap gap
> >
<Row alignItems="center"> <Row alignItems="center">
<Text truncate={true} style={{ maxWidth: isMobile ? '200px' : '400px' }}> <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}>
{label} {label}
</Text> </Text>
</Row> </Row>

View file

@ -25,16 +25,16 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
const { getRegionName } = useRegionNames(locale); const { getRegionName } = useRegionNames(locale);
const { label, country, domain } = data; const { label, country, domain } = data;
const isType = ['browser', 'country', 'device', 'os'].includes(type);
switch (type) { switch (type) {
case 'browser': case 'browser':
case 'os':
return ( return (
<FilterLink <FilterLink
type="browser" type={type}
value={label} value={label}
label={formatValue(label, 'browser')} label={formatValue(label, type)}
icon={<TypeIcon type="browser" value={label} />} icon={<TypeIcon type={type} value={label} />}
/> />
); );
@ -100,7 +100,7 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
type="device" type="device"
value={labels[label] && label} value={labels[label] && label}
label={formatValue(label, 'device')} label={formatValue(label, 'device')}
icon={<TypeIcon type="device" value={label?.toLowerCase()} />} icon={<TypeIcon type="device" value={label} />}
/> />
); );
@ -141,14 +141,6 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
<FilterLink <FilterLink
type={type} type={type}
value={label} value={label}
icon={
isType && (
<TypeIcon
type={type as 'browser' | 'country' | 'device' | 'os'}
value={label?.toLowerCase()?.replaceAll(/\W/g, '-')}
/>
)
}
/> />
); );
} }

View file

@ -7,7 +7,7 @@ export interface MetricsBarProps extends GridProps {
export function MetricsBar({ children, ...props }: MetricsBarProps) { export function MetricsBar({ children, ...props }: MetricsBarProps) {
return ( return (
<Grid columns="repeat(auto-fit, minmax(140px, 1fr))" gap {...props}> <Grid columns="repeat(auto-fit, minmax(160px, 1fr))" gap {...props}>
{children} {children}
</Grid> </Grid>
); );

View file

@ -3,6 +3,7 @@ import { startOfMinute, subMinutes, isBefore } from 'date-fns';
import { PageviewsChart } from './PageviewsChart'; import { PageviewsChart } from './PageviewsChart';
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants'; import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
import { RealtimeData } from '@/lib/types'; import { RealtimeData } from '@/lib/types';
import { useTimezone } from '@/components/hooks';
export interface RealtimeChartProps { export interface RealtimeChartProps {
data: RealtimeData; data: RealtimeData;
@ -11,6 +12,7 @@ export interface RealtimeChartProps {
} }
export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) { export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
const endDate = startOfMinute(new Date()); const endDate = startOfMinute(new Date());
const startDate = subMinutes(endDate, REALTIME_RANGE); const startDate = subMinutes(endDate, REALTIME_RANGE);
const prevEndDate = useRef(endDate); const prevEndDate = useRef(endDate);
@ -21,8 +23,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
} }
return { return {
pageviews: data.series.views, pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
sessions: data.series.visitors, sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
}; };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
@ -38,8 +40,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
return ( return (
<PageviewsChart <PageviewsChart
{...props} {...props}
minDate={startDate} minDate={fromUtc(startDate)}
maxDate={endDate} maxDate={fromUtc(endDate)}
unit={unit} unit={unit}
data={chartData} data={chartData}
animationDuration={animationDuration} animationDuration={animationDuration}

View file

@ -61,7 +61,7 @@ function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
function getDateSQL(field: string, unit: string, timezone?: string) { function getDateSQL(field: string, unit: string, timezone?: string) {
if (timezone) { if (timezone) {
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`; return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`;
} }
return `toDateTime(date_trunc('${unit}', ${field}))`; return `toDateTime(date_trunc('${unit}', ${field}))`;
} }

View file

@ -5,6 +5,7 @@ export const TIMEZONE_CONFIG = 'umami.timezone';
export const DATE_RANGE_CONFIG = 'umami.date-range'; export const DATE_RANGE_CONFIG = 'umami.date-range';
export const THEME_CONFIG = 'umami.theme'; export const THEME_CONFIG = 'umami.theme';
export const DASHBOARD_CONFIG = 'umami.dashboard'; export const DASHBOARD_CONFIG = 'umami.dashboard';
export const LAST_TEAM_CONFIG = 'umami.last-team';
export const VERSION_CHECK = 'umami.version-check'; export const VERSION_CHECK = 'umami.version-check';
export const SHARE_TOKEN_HEADER = 'x-umami-share-token'; export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
export const HOMEPAGE_URL = 'https://umami.is'; export const HOMEPAGE_URL = 'https://umami.is';
@ -658,3 +659,24 @@ export const CURRENCIES = [
{ id: 'OMR', name: 'Omani Rial' }, { id: 'OMR', name: 'Omani Rial' },
{ id: 'GHS', name: 'Ghanaian Cedi' }, { id: 'GHS', name: 'Ghanaian Cedi' },
]; ];
export const TIMEZONE_LEGACY: Record<string, string> = {
'Asia/Batavia': 'Asia/Jakarta',
'Asia/Calcutta': 'Asia/Kolkata',
'Asia/Chongqing': 'Asia/Shanghai',
'Asia/Harbin': 'Asia/Shanghai',
'Asia/Jayapura': 'Asia/Pontianak',
'Asia/Katmandu': 'Asia/Kathmandu',
'Asia/Macao': 'Asia/Macau',
'Asia/Rangoon': 'Asia/Yangon',
'Asia/Saigon': 'Asia/Ho_Chi_Minh',
'Europe/Kiev': 'Europe/Kyiv',
'Europe/Zaporozhye': 'Europe/Kyiv',
'Etc/UTC': 'UTC',
'US/Arizona': 'America/Phoenix',
'US/Central': 'America/Chicago',
'US/Eastern': 'America/New_York',
'US/Mountain': 'America/Denver',
'US/Pacific': 'America/Los_Angeles',
'US/Samoa': 'Pacific/Pago_Pago',
};

View file

@ -1,44 +1,45 @@
import {
addMinutes,
addHours,
addDays,
addMonths,
addYears,
subMinutes,
subHours,
subDays,
subMonths,
subYears,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
startOfMonth,
startOfYear,
endOfHour,
endOfDay,
endOfWeek,
endOfMonth,
endOfYear,
differenceInMinutes,
differenceInHours,
differenceInCalendarDays,
differenceInCalendarWeeks,
differenceInCalendarMonths,
differenceInCalendarYears,
format,
max,
min,
isDate,
addWeeks,
subWeeks,
endOfMinute,
isSameDay,
isBefore,
isEqual,
} from 'date-fns';
import { getDateLocale } from '@/lib/lang'; import { getDateLocale } from '@/lib/lang';
import { DateRange } from '@/lib/types'; import { DateRange } from '@/lib/types';
import {
addDays,
addHours,
addMinutes,
addMonths,
addWeeks,
addYears,
differenceInCalendarDays,
differenceInCalendarMonths,
differenceInCalendarWeeks,
differenceInCalendarYears,
differenceInHours,
differenceInMinutes,
endOfDay,
endOfHour,
endOfMinute,
endOfMonth,
endOfWeek,
endOfYear,
format,
isBefore,
isDate,
isEqual,
isSameDay,
max,
min,
startOfDay,
startOfHour,
startOfMinute,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subHours,
subMinutes,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
export const TIME_UNIT = { export const TIME_UNIT = {
minute: 'minute', minute: 'minute',
@ -135,7 +136,7 @@ export function parseDateValue(value: string) {
return { num: +num, unit }; return { num: +num, unit };
} }
export function parseDateRange(value: string, locale = 'en-US'): DateRange { export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null; return null;
} }
@ -156,7 +157,8 @@ export function parseDateRange(value: string, locale = 'en-US'): DateRange {
}; };
} }
const now = new Date(); const date = new Date();
const now = timezone ? utcToZonedTime(date, timezone) : date;
const dateLocale = getDateLocale(locale); const dateLocale = getDateLocale(locale);
const { num = 1, unit } = parseDateValue(value); const { num = 1, unit } = parseDateValue(value);
@ -367,3 +369,7 @@ export function getDateRangeValue(startDate: Date, endDate: Date) {
export function getMonthDateRangeValue(date: Date) { export function getMonthDateRangeValue(date: Date) {
return getDateRangeValue(startOfMonth(date), endOfMonth(date)); return getDateRangeValue(startOfMonth(date), endOfMonth(date));
} }
export function isInvalidDate(date: any) {
return date instanceof Date && isNaN(date.getTime());
}

View file

@ -6,7 +6,7 @@ export const IP_ADDRESS_HEADERS = [
'fastly-client-ip', // Fastly 'fastly-client-ip', // Fastly
'x-nf-client-connection-ip', // Netlify 'x-nf-client-connection-ip', // Netlify
'do-connecting-ip', // Digital Ocean 'do-connecting-ip', // Digital Ocean
'x-appengine-user-ip', // Google App Ending 'x-appengine-user-ip', // Google App Engine
'x-client-ip', 'x-client-ip',
'x-cluster-client-ip', 'x-cluster-client-ip',
'x-forwarded', 'x-forwarded',

View file

@ -27,6 +27,14 @@ const DATE_FORMATS = {
year: 'YYYY-01-01 HH24:00:00', year: 'YYYY-01-01 HH24:00:00',
}; };
const DATE_FORMATS_UTC = {
minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
month: 'YYYY-MM-01"T"HH24:00:00"Z"',
year: 'YYYY-01-01"T"HH24:00:00"Z"',
};
function getAddIntervalQuery(field: string, interval: string): string { function getAddIntervalQuery(field: string, interval: string): string {
return `${field} + interval '${interval}'`; return `${field} + interval '${interval}'`;
} }
@ -40,11 +48,11 @@ function getCastColumnQuery(field: string, type: string): string {
} }
function getDateSQL(field: string, unit: string, timezone?: string): string { function getDateSQL(field: string, unit: string, timezone?: string): string {
if (timezone) { if (timezone && timezone !== 'utc') {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`; return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
} }
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`; return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
} }
function getDateWeeklySQL(field: string, timezone?: string) { function getDateWeeklySQL(field: string, timezone?: string) {

View file

@ -116,3 +116,23 @@ export interface PageResult<T> {
sortDescending?: boolean; sortDescending?: boolean;
search?: string; search?: string;
} }
export interface RealtimeData {
countries: Record<string, number>;
events: any[];
pageviews: any[];
referrers: Record<string, number>;
timestamp: number;
series: {
views: any[];
visitors: any[];
};
totals: {
views: number;
visitors: number;
events: number;
countries: number;
};
urls: Record<string, number>;
visitors: any[];
}

View file

@ -135,26 +135,31 @@ export async function resetWebsite(websiteId: string) {
const { client, transaction } = prisma; const { client, transaction } = prisma;
const cloudMode = !!process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_MODE;
return transaction([ return transaction(
client.eventData.deleteMany({ [
where: { websiteId }, client.eventData.deleteMany({
}), where: { websiteId },
client.sessionData.deleteMany({ }),
where: { websiteId }, client.sessionData.deleteMany({
}), where: { websiteId },
client.websiteEvent.deleteMany({ }),
where: { websiteId }, client.websiteEvent.deleteMany({
}), where: { websiteId },
client.session.deleteMany({ }),
where: { websiteId }, client.session.deleteMany({
}), where: { websiteId },
client.website.update({ }),
where: { id: websiteId }, client.website.update({
data: { where: { id: websiteId },
resetAt: new Date(), data: {
}, resetAt: new Date(),
}), },
]).then(async data => { }),
],
{
timeout: 30000,
},
).then(async data => {
if (cloudMode) { if (cloudMode) {
await redis.client.set( await redis.client.set(
`website:${websiteId}`, `website:${websiteId}`,

View file

@ -19,20 +19,20 @@ async function relationalQuery(websiteId: string, eventId: string) {
return rawQuery( return rawQuery(
` `
select website_id as "websiteId", select event_data.website_id as "websiteId",
session_id as "sessionId", event_data.website_event_id as "eventId",
event_id as "eventId", website_event.event_name as "eventName",
url_path as "urlPath", event_data.data_key as "dataKey",
event_name as "eventName", event_data.string_value as "stringValue",
data_key as "dataKey", event_data.number_value as "numberValue",
string_value as "stringValue", event_data.date_value as "dateValue",
number_value as "numberValue", event_data.data_type as "dataType",
date_value as "dateValue", event_data.created_at as "createdAt"
data_type as "dataType",
created_at as "createdAt"
from event_data from event_data
website_id = {{websiteId::uuid}} join website_event on website_event.event_id = event_data.website_event_id
event_id = {{eventId::uuid}} and website_event.website_id = {{websiteId::uuid}}
where event_data.website_id = {{websiteId::uuid}}
and event_data.website_event_id = {{eventId::uuid}}
`, `,
{ websiteId, eventId }, { websiteId, eventId },
FUNCTION_NAME, FUNCTION_NAME,
@ -45,9 +45,7 @@ async function clickhouseQuery(websiteId: string, eventId: string): Promise<Even
return rawQuery( return rawQuery(
` `
select website_id as websiteId, select website_id as websiteId,
session_id as sessionId,
event_id as eventId, event_id as eventId,
url_path as urlPath,
event_name as eventName, event_name as eventName,
data_key as dataKey, data_key as dataKey,
string_value as stringValue, string_value as stringValue,

View file

@ -45,7 +45,11 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
browser as browser, browser as browser,
page_title as "pageTitle", page_title as "pageTitle",
website_event.event_type as "eventType", website_event.event_type as "eventType",
website_event.event_name as "eventName" website_event.event_name as "eventName",
event_id IN (select website_event_id
from event_data
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}) AS "hasData"
from website_event from website_event
${cohortQuery} ${cohortQuery}
join session on session.session_id = website_event.session_id join session on session.session_id = website_event.session_id

View file

@ -1,7 +1,7 @@
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
import { QueryFilters } from '@/lib/types'; import { QueryFilters } from '@/lib/types';
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
function increment(data: object, key: string) { function increment(data: object, key: string) {
if (key) { if (key) {

View file

@ -36,11 +36,11 @@ async function relationalQuery(
return rawQuery( return rawQuery(
` `
select select
sum(t.c) as "pageviews", cast(coalesce(sum(t.c), 0) as bigint) as "pageviews",
count(distinct t.session_id) as "visitors", count(distinct t.session_id) as "visitors",
count(distinct t.visit_id) as "visits", count(distinct t.visit_id) as "visits",
sum(case when t.c = 1 then 1 else 0 end) as "bounces", coalesce(sum(case when t.c = 1 then 1 else 0 end), 0) as "bounces",
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" cast(coalesce(sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}), 0) as bigint) as "totaltime"
from ( from (
select select
website_event.session_id, website_event.session_id,

View file

@ -45,7 +45,7 @@ async function clickhouseQuery(
websiteId: string, websiteId: string,
filters: QueryFilters, filters: QueryFilters,
): Promise<{ x: string; y: number }[]> { ): Promise<{ x: string; y: number }[]> {
const { timezone = 'utc', unit = 'day' } = filters; const { timezone = 'UTC', unit = 'day' } = filters;
const { parseFilters, rawQuery, getDateSQL } = clickhouse; const { parseFilters, rawQuery, getDateSQL } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,

View file

@ -73,7 +73,7 @@ async function relationalQuery(
for (let i = 1; i <= steps; i++) { for (let i = 1; i <= steps; i++) {
const endQuery = i < steps ? ',' : ''; const endQuery = i < steps ? ',' : '';
selectQuery += `s.e${i},`; selectQuery += `s.e${i},`;
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN event ELSE NULL END) AS e${i}${endQuery}`; maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`;
groupByQuery += `s.e${i}${endQuery} `; groupByQuery += `s.e${i}${endQuery} `;
} }
@ -185,7 +185,7 @@ async function clickhouseQuery(
for (let i = 1; i <= steps; i++) { for (let i = 1; i <= steps; i++) {
const endQuery = i < steps ? ',' : ''; const endQuery = i < steps ? ',' : '';
selectQuery += `s.e${i},`; selectQuery += `s.e${i},`;
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN event ELSE NULL END) AS e${i}${endQuery}`; maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`;
groupByQuery += `s.e${i}${endQuery} `; groupByQuery += `s.e${i}${endQuery} `;
} }
@ -230,7 +230,7 @@ async function clickhouseQuery(
WITH events AS ( WITH events AS (
select distinct select distinct
visit_id, visit_id,
coalesce(nullIf(event_name, ''), url_path) event, coalesce(nullIf(event_name, ''), url_path) "event",
row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number
from website_event from website_event
${cohortQuery} ${cohortQuery}

View file

@ -133,7 +133,7 @@ async function clickhouseQuery(
user_activities AS ( user_activities AS (
select distinct select distinct
website_event.session_id, website_event.session_id,
(${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400 as day_number toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number
from website_event from website_event
join cohort_items join cohort_items
on website_event.session_id = cohort_items.session_id on website_event.session_id = cohort_items.session_id

View file

@ -41,6 +41,15 @@ async function relationalQuery(
currency, currency,
}); });
const joinQuery = filterQuery
? `join website_event
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.event_id = revenue.event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}`
: '';
const chart = await rawQuery( const chart = await rawQuery(
` `
select select
@ -48,17 +57,12 @@ async function relationalQuery(
${getDateSQL('revenue.created_at', unit, timezone)} t, ${getDateSQL('revenue.created_at', unit, timezone)} t,
sum(revenue.revenue) y sum(revenue.revenue) y
from revenue from revenue
join website_event ${joinQuery}
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.event_id = revenue.event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${cohortQuery} ${cohortQuery}
${joinSessionQuery} ${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}} where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}} and revenue.created_at between {{startDate}} and {{endDate}}
and revenue.currency like {{currency}} and revenue.currency ilike {{currency}}
${filterQuery} ${filterQuery}
group by x, t group by x, t
order by t order by t
@ -72,19 +76,14 @@ async function relationalQuery(
session.country as name, session.country as name,
sum(revenue) value sum(revenue) value
from revenue from revenue
join website_event ${joinQuery}
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.event_id = revenue.event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
join session join session
on session.website_id = revenue.website_id on session.website_id = revenue.website_id
and session.session_id = revenue.session_id and session.session_id = revenue.session_id
${cohortQuery} ${cohortQuery}
where revenue.website_id = {{websiteId::uuid}} where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}} and revenue.created_at between {{startDate}} and {{endDate}}
and revenue.currency = {{currency}} and revenue.currency ilike {{currency}}
${filterQuery} ${filterQuery}
group by session.country group by session.country
`, `,
@ -98,23 +97,18 @@ async function relationalQuery(
count(distinct revenue.event_id) as count, count(distinct revenue.event_id) as count,
count(distinct revenue.session_id) as unique_count count(distinct revenue.session_id) as unique_count
from revenue from revenue
join website_event ${joinQuery}
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.event_id = revenue.event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${cohortQuery} ${cohortQuery}
${joinSessionQuery} ${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}} where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}} and revenue.created_at between {{startDate}} and {{endDate}}
and revenue.currency = {{currency}} and revenue.currency ilike {{currency}}
${filterQuery} ${filterQuery}
`, `,
queryParams, queryParams,
).then(result => result?.[0]); ).then(result => result?.[0]);
total.average = total.count > 0 ? total.sum / total.count : 0; total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0;
return { chart, country, total }; return { chart, country, total };
} }

View file

@ -1,41 +1,44 @@
import { Prisma } from '@/generated/prisma/client'; import { Prisma } from '@/generated/prisma/client';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
export async function createSession(data: Prisma.SessionCreateInput) { const FUNCTION_NAME = 'createSession';
const {
id,
websiteId,
browser,
os,
device,
screen,
language,
country,
region,
city,
distinctId,
} = data;
try { export async function createSession(data: Prisma.SessionCreateInput) {
return await prisma.client.session.create({ const { rawQuery } = prisma;
data: {
id, await rawQuery(
websiteId, `
browser, insert into session (
os, session_id,
device, website_id,
screen, browser,
language, os,
country, device,
region, screen,
city, language,
distinctId, country,
}, region,
}); city,
} catch (e: any) { distinct_id,
if (e.message.toLowerCase().includes('unique constraint')) { created_at
return null; )
} values (
throw e; {{id}},
} {{websiteId}},
{{browser}},
{{os}},
{{device}},
{{screen}},
{{language}},
{{country}},
{{region}},
{{city}},
{{distinctId}},
{{createdAt}}
)
on conflict (session_id) do nothing
`,
data,
FUNCTION_NAME,
);
} }

View file

@ -29,10 +29,10 @@ async function relationalQuery(websiteId: string, sessionId: string, filters: Qu
event_type as "eventType", event_type as "eventType",
event_name as "eventName", event_name as "eventName",
visit_id as "visitId", visit_id as "visitId",
event_id IN (select event_id event_id IN (select website_event_id
from event_data from event_data
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and session_id = {{sessionId::uuid}}) AS "hasData" and created_at between {{startDate}} and {{endDate}}) AS "hasData"
from website_event from website_event
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and session_id = {{sessionId::uuid}} and session_id = {{sessionId::uuid}}

View file

@ -45,7 +45,7 @@ async function clickhouseQuery(
websiteId: string, websiteId: string,
filters: QueryFilters, filters: QueryFilters,
): Promise<{ x: string; y: number }[]> { ): Promise<{ x: string; y: number }[]> {
const { timezone = 'utc', unit = 'day' } = filters; const { timezone = 'UTC', unit = 'day' } = filters;
const { parseFilters, rawQuery, getDateSQL } = clickhouse; const { parseFilters, rawQuery, getDateSQL } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,

View file

@ -18,6 +18,7 @@
const _false = 'false'; const _false = 'false';
const _true = 'true'; const _true = 'true';
const attr = currentScript.getAttribute.bind(currentScript); const attr = currentScript.getAttribute.bind(currentScript);
const website = attr(_data + 'website-id'); const website = attr(_data + 'website-id');
const hostUrl = attr(_data + 'host-url'); const hostUrl = attr(_data + 'host-url');
const beforeSend = attr(_data + 'before-send'); const beforeSend = attr(_data + 'before-send');
@ -27,6 +28,8 @@
const excludeSearch = attr(_data + 'exclude-search') === _true; const excludeSearch = attr(_data + 'exclude-search') === _true;
const excludeHash = attr(_data + 'exclude-hash') === _true; const excludeHash = attr(_data + 'exclude-hash') === _true;
const domain = attr(_data + 'domains') || ''; const domain = attr(_data + 'domains') || '';
const credentials = attr(_data + 'fetch-credentials') || 'omit';
const domains = domain.split(',').map(n => n.trim()); const domains = domain.split(',').map(n => n.trim());
const host = const host =
hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/'); hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
@ -45,7 +48,7 @@
if (excludeSearch) u.search = ''; if (excludeSearch) u.search = '';
if (excludeHash) u.hash = ''; if (excludeHash) u.hash = '';
return u.toString(); return u.toString();
} catch (e) { } catch {
return raw; return raw;
} }
}; };
@ -165,7 +168,7 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }), ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }),
}, },
credentials: 'omit', credentials,
}); });
const data = await res.json(); const data = await res.json();