diff --git a/README.md b/README.md index 6d9755d4..33a5aac3 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,93 @@ -# umami +

+ Umami Logo +

-Umami is a simple, fast, privacy-focused alternative to Google Analytics. +

Umami

-## Getting started +

+ Umami is a simple, fast, privacy-focused alternative to Google Analytics. +

-A detailed getting started guide can be found at [https://umami.is/docs/](https://umami.is/docs/) +

+ + GitHub Release + + + MIT License + + + Build Status + + + Umami Demo + +

-## Installing from source +--- + +## 🚀 Getting Started + +A detailed getting started guide can be found at [umami.is/docs](https://umami.is/docs/). + +--- + +## 🛠 Installing from Source ### Requirements - A server with Node.js version 16.13 or newer -- A database. Umami supports [MySQL](https://www.mysql.com/) (minimum v8.0) and [Postgresql](https://www.postgresql.org/) (minimum v12.14) databases. +- A database. Umami supports [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases. ### Install Yarn -``` +```bash npm install -g yarn ``` -### Get the source code and install packages +### Get the Source Code and Install Packages -``` +```bash git clone https://github.com/umami-software/umami.git cd umami yarn install ``` -### Configure umami +### Configure Umami -Create an `.env` file with the following +Create an `.env` file with the following: -``` +```bash DATABASE_URL=connection-url ``` -The connection url is in the following format: +The connection URL format: -``` +```bash postgresql://username:mypassword@localhost:5432/mydb - mysql://username:mypassword@localhost:3306/mydb ``` -### Build the application +### Build the Application ```bash yarn build ``` -The build step will also create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**. +*The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.* -### Start the application +### Start the Application ```bash yarn start ``` -By default this will launch the application on `http://localhost:3000`. You will need to either -[proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server -or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly. +*By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.* -## Installing with Docker +--- -To build the umami container and start up a Postgres database, run: +## 🐳 Installing with Docker + +To build the Umami container and start up a Postgres database, run: ```bash docker compose up -d @@ -81,7 +105,9 @@ Or with MySQL support: docker pull docker.umami.is/umami-software/umami:mysql-latest ``` -## Getting updates +--- + +## 🔄 Getting Updates To get the latest features, simply do a pull, install any new dependencies, and rebuild: @@ -98,7 +124,36 @@ docker compose pull docker compose up --force-recreate ``` -## License +--- -MIT +## 🛟 Support +

+ + GitHub + + + Twitter + + + LinkedIn + + + Discord + +

+ +[release-shield]: https://img.shields.io/github/release/umami-software/umami.svg +[releases-url]: https://github.com/umami-software/umami/releases +[license-shield]: https://img.shields.io/github/license/umami-software/umami.svg +[license-url]: https://github.com/umami-software/umami/blob/master/LICENSE +[build-shield]: https://img.shields.io/github/actions/workflow/status/umami-software/umami/ci.yml +[build-url]: https://github.com/umami-software/umami/actions +[github-shield]: https://img.shields.io/badge/GitHub--blue?style=social&logo=github +[github-url]: https://github.com/umami-software/umami +[twitter-shield]: https://img.shields.io/badge/Twitter--blue?style=social&logo=twitter +[twitter-url]: https://twitter.com/umami_software +[linkedin-shield]: https://img.shields.io/badge/LinkedIn--blue?style=social&logo=linkedin +[linkedin-url]: https://linkedin.com/company/umami-software +[discord-shield]: https://img.shields.io/badge/Discord--blue?style=social&logo=discord +[discord-url]: https://discord.com/invite/4dz4zcXYrQ diff --git a/package.json b/package.json index be5bb5ac..7f5c7c48 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,9 @@ ".next/cache" ], "dependencies": { - "@clickhouse/client": "^1.0.1", + "@clickhouse/client": "^1.0.2", "@fontsource/inter": "^4.5.15", - "@prisma/client": "5.13.0", + "@prisma/client": "5.14.0", "@prisma/extension-read-replicas": "^0.3.0", "@react-spring/web": "^9.7.3", "@tanstack/react-query": "^5.28.6", @@ -102,7 +102,7 @@ "next-basics": "^0.39.0", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", - "prisma": "5.13.0", + "prisma": "5.14.0", "react": "^18.2.0", "react-basics": "^0.123.0", "react-beautiful-dnd": "^13.1.0", diff --git a/public/intl/messages/am-ET.json b/public/intl/messages/am-ET.json index 931935a2..a3b652bb 100644 --- a/public/intl/messages/am-ET.json +++ b/public/intl/messages/am-ET.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Average visit time" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Visit duration" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ar-SA.json b/public/intl/messages/ar-SA.json index 300d3e1a..15f9647c 100644 --- a/public/intl/messages/ar-SA.json +++ b/public/intl/messages/ar-SA.json @@ -83,12 +83,6 @@ "value": "المتوسط" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "متوسط وقت الزيارة" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "متوسط وقت الزيارة" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/be-BY.json b/public/intl/messages/be-BY.json index 4b8c964f..22557a24 100644 --- a/public/intl/messages/be-BY.json +++ b/public/intl/messages/be-BY.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Сярэдняя даўжыня наведвання" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Сярэдняя даўжыня наведвання" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/bn-BD.json b/public/intl/messages/bn-BD.json index 05de48d2..81661e3b 100644 --- a/public/intl/messages/bn-BD.json +++ b/public/intl/messages/bn-BD.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "গড় পরিদর্শনের সময়" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "গড় পরিদর্শনের সময়" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/bs-BA.json b/public/intl/messages/bs-BA.json index 1360efab..314e689e 100644 --- a/public/intl/messages/bs-BA.json +++ b/public/intl/messages/bs-BA.json @@ -83,12 +83,6 @@ "value": "Prosjek" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Prosječno vrijeme posjete" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Pregledi po posjeti" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Prosječno vrijeme posjete" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ca-ES.json b/public/intl/messages/ca-ES.json index e6cb4c5e..911dd04b 100644 --- a/public/intl/messages/ca-ES.json +++ b/public/intl/messages/ca-ES.json @@ -83,12 +83,6 @@ "value": "Mitjana" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Temps mitjà de visita" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Temps mitjà de visita" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/cs-CZ.json b/public/intl/messages/cs-CZ.json index f87ddeda..c329fa58 100644 --- a/public/intl/messages/cs-CZ.json +++ b/public/intl/messages/cs-CZ.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Průměrný čas návštěvy" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Průměrný čas návštěvy" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/da-DK.json b/public/intl/messages/da-DK.json index 332b98e8..c242aaf3 100644 --- a/public/intl/messages/da-DK.json +++ b/public/intl/messages/da-DK.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Gennemsnitlig besøgstid" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Gennemsnitlig besøgstid" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/de-CH.json b/public/intl/messages/de-CH.json index 9b913cea..69c48a1f 100644 --- a/public/intl/messages/de-CH.json +++ b/public/intl/messages/de-CH.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Durchschn. Bsuechsziit" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Durchschn. Bsuechsziit" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/de-DE.json b/public/intl/messages/de-DE.json index 9a21c400..2faf7770 100644 --- a/public/intl/messages/de-DE.json +++ b/public/intl/messages/de-DE.json @@ -83,12 +83,6 @@ "value": "Durchschnitt" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Durchschn. Besuchszeit" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Durchschn. Besuchszeit" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/el-GR.json b/public/intl/messages/el-GR.json index 7c58f14c..5c39e267 100644 --- a/public/intl/messages/el-GR.json +++ b/public/intl/messages/el-GR.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Μέσος χρόνος επίσκεψης" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Μέσος χρόνος επίσκεψης" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/en-GB.json b/public/intl/messages/en-GB.json index 43c9019e..b7beda36 100644 --- a/public/intl/messages/en-GB.json +++ b/public/intl/messages/en-GB.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Average visit time" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Visit duration" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 1cb11f91..42f3bbd9 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Average visit time" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Visit duration" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/es-ES.json b/public/intl/messages/es-ES.json index 32b34940..be0835ae 100644 --- a/public/intl/messages/es-ES.json +++ b/public/intl/messages/es-ES.json @@ -83,12 +83,6 @@ "value": "Media" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Tiempo promedio de visita" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Tiempo promedio de visita" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/es-MX.json b/public/intl/messages/es-MX.json index c3ef099d..8051cde5 100644 --- a/public/intl/messages/es-MX.json +++ b/public/intl/messages/es-MX.json @@ -71,7 +71,7 @@ "value": "Average" } ], - "label.average-visit-time": [ + "label.visit-duration": [ { "type": 0, "value": "Tiempo promedio de visita" diff --git a/public/intl/messages/fa-IR.json b/public/intl/messages/fa-IR.json index 8c894c6a..cc0e81c0 100644 --- a/public/intl/messages/fa-IR.json +++ b/public/intl/messages/fa-IR.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "میانگین زمان بازدید" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "میانگین زمان بازدید" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/fi-FI.json b/public/intl/messages/fi-FI.json index f855dba3..13a597f4 100644 --- a/public/intl/messages/fi-FI.json +++ b/public/intl/messages/fi-FI.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Keskimääräinen vierailuaika" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Keskimääräinen vierailuaika" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/fo-FO.json b/public/intl/messages/fo-FO.json index be7442eb..b3d62589 100644 --- a/public/intl/messages/fo-FO.json +++ b/public/intl/messages/fo-FO.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Miðal vitjurnartíð " - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Miðal vitjurnartíð " + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json index e33b7bf2..4e5f3ed5 100644 --- a/public/intl/messages/fr-FR.json +++ b/public/intl/messages/fr-FR.json @@ -83,12 +83,6 @@ "value": "Moyenne" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Temps de visite moyen" - } - ], "label.back": [ { "type": 0, @@ -1237,6 +1231,12 @@ "value": "Vues par visite" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Temps de visite moyen" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ga-ES.json b/public/intl/messages/ga-ES.json index 4bc03ce0..1ce652f9 100644 --- a/public/intl/messages/ga-ES.json +++ b/public/intl/messages/ga-ES.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Tempo medio de visita" - } - ], "label.back": [ { "type": 0, @@ -1257,6 +1251,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Tempo medio de visita" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/he-IL.json b/public/intl/messages/he-IL.json index 92aef8a0..af8a6fb1 100644 --- a/public/intl/messages/he-IL.json +++ b/public/intl/messages/he-IL.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "זמן ביקור ממוצע" - } - ], "label.back": [ { "type": 0, @@ -1241,6 +1235,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "זמן ביקור ממוצע" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/hi-IN.json b/public/intl/messages/hi-IN.json index ec463181..e4c8a2ba 100644 --- a/public/intl/messages/hi-IN.json +++ b/public/intl/messages/hi-IN.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "औसत दृश्य समय" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "औसत दृश्य समय" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/hr-HR.json b/public/intl/messages/hr-HR.json index 032456f8..9933e840 100644 --- a/public/intl/messages/hr-HR.json +++ b/public/intl/messages/hr-HR.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Average visit time" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Visit duration" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/hu-HU.json b/public/intl/messages/hu-HU.json index 08fd319a..3fccf7a7 100644 --- a/public/intl/messages/hu-HU.json +++ b/public/intl/messages/hu-HU.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Átlagos látogatási idő" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Átlagos látogatási idő" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/id-ID.json b/public/intl/messages/id-ID.json index 8898f1ec..7dfe2e2f 100644 --- a/public/intl/messages/id-ID.json +++ b/public/intl/messages/id-ID.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Waktu kunjungan rata-rata" - } - ], "label.back": [ { "type": 0, @@ -1241,6 +1235,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Waktu kunjungan rata-rata" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/it-IT.json b/public/intl/messages/it-IT.json index 471cf31f..d1568bae 100644 --- a/public/intl/messages/it-IT.json +++ b/public/intl/messages/it-IT.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Tempo medio di visita" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Tempo medio di visita" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ja-JP.json b/public/intl/messages/ja-JP.json index ed869857..55c3cae0 100644 --- a/public/intl/messages/ja-JP.json +++ b/public/intl/messages/ja-JP.json @@ -83,12 +83,6 @@ "value": "平均" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "平均滞在時間" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "平均滞在時間" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/km-KH.json b/public/intl/messages/km-KH.json index 63ad4841..7ddac468 100644 --- a/public/intl/messages/km-KH.json +++ b/public/intl/messages/km-KH.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "មើលជាមធ្យម" - } - ], "label.back": [ { "type": 0, @@ -1241,6 +1235,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "មើលជាមធ្យម" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ko-KR.json b/public/intl/messages/ko-KR.json index e9fc0c0d..48dfbb3b 100644 --- a/public/intl/messages/ko-KR.json +++ b/public/intl/messages/ko-KR.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "평균 방문 시간" - } - ], "label.back": [ { "type": 0, @@ -1253,6 +1247,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "평균 방문 시간" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/lt-LT.json b/public/intl/messages/lt-LT.json index 261e2f94..727e0c51 100644 --- a/public/intl/messages/lt-LT.json +++ b/public/intl/messages/lt-LT.json @@ -83,12 +83,6 @@ "value": "Vidurkis" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Vidutinė vizito trukmė" - } - ], "label.back": [ { "type": 0, @@ -1354,6 +1348,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Vidutinė vizito trukmė" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/mn-MN.json b/public/intl/messages/mn-MN.json index 48334902..484b6752 100644 --- a/public/intl/messages/mn-MN.json +++ b/public/intl/messages/mn-MN.json @@ -83,12 +83,6 @@ "value": "Дундаж" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Зочилсон дундаж хугацаа" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Зочилсон дундаж хугацаа" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ms-MY.json b/public/intl/messages/ms-MY.json index 58034742..7d9b8369 100644 --- a/public/intl/messages/ms-MY.json +++ b/public/intl/messages/ms-MY.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Purata tempoh masa lawatan" - } - ], "label.back": [ { "type": 0, @@ -1241,6 +1235,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Purata tempoh masa lawatan" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/my-MM.json b/public/intl/messages/my-MM.json index 18c1a2b9..f56429aa 100644 --- a/public/intl/messages/my-MM.json +++ b/public/intl/messages/my-MM.json @@ -83,12 +83,6 @@ "value": "ပျမ်းမျှ" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "ဝဘက်ဘ်ဆိုဒ်တွင် ပျမ်းမျှကုန်ဆုံးချိန်" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "ဝဘက်ဘ်ဆိုဒ်တွင် ပျမ်းမျှကုန်ဆုံးချိန်" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/nb-NO.json b/public/intl/messages/nb-NO.json index 2f7132ef..7b110ee4 100644 --- a/public/intl/messages/nb-NO.json +++ b/public/intl/messages/nb-NO.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Gjennomsnittlig besøkstid" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Gjennomsnittlig besøkstid" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/nl-NL.json b/public/intl/messages/nl-NL.json index c0a3add4..5b0cfdbf 100644 --- a/public/intl/messages/nl-NL.json +++ b/public/intl/messages/nl-NL.json @@ -83,12 +83,6 @@ "value": "Gemiddelde" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Gemiddelde bezoektijd" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Gemiddelde bezoektijd" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/pl-PL.json b/public/intl/messages/pl-PL.json index 547a70a9..4a5a41ee 100644 --- a/public/intl/messages/pl-PL.json +++ b/public/intl/messages/pl-PL.json @@ -83,12 +83,6 @@ "value": "Średnia" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Średni czas wizyty" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Średni czas wizyty" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/pt-BR.json b/public/intl/messages/pt-BR.json index 7c41e5ef..6e3bb03e 100644 --- a/public/intl/messages/pt-BR.json +++ b/public/intl/messages/pt-BR.json @@ -83,12 +83,6 @@ "value": "Média" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Tempo médio de visita" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Visualizações por visita" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Tempo médio de visita" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/pt-PT.json b/public/intl/messages/pt-PT.json index 3e16a55e..1b19e8d3 100644 --- a/public/intl/messages/pt-PT.json +++ b/public/intl/messages/pt-PT.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Tempo médio de visita" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Tempo médio de visita" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ro-RO.json b/public/intl/messages/ro-RO.json index da1413f6..6a0b77fe 100644 --- a/public/intl/messages/ro-RO.json +++ b/public/intl/messages/ro-RO.json @@ -83,12 +83,6 @@ "value": "Mediu" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Timp mediu de vizitare" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Timp mediu de vizitare" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ru-RU.json b/public/intl/messages/ru-RU.json index 4b97f225..4f851f46 100644 --- a/public/intl/messages/ru-RU.json +++ b/public/intl/messages/ru-RU.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Среднее время посещения" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Среднее время посещения" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/si-LK.json b/public/intl/messages/si-LK.json index 67e32f90..749d830b 100644 --- a/public/intl/messages/si-LK.json +++ b/public/intl/messages/si-LK.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Average visit time" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Visit duration" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/sk-SK.json b/public/intl/messages/sk-SK.json index f4e8a5e4..ff9d4e65 100644 --- a/public/intl/messages/sk-SK.json +++ b/public/intl/messages/sk-SK.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Priemerný čas návštevy" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Priemerný čas návštevy" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/sl-SI.json b/public/intl/messages/sl-SI.json index e96723ae..b2091166 100644 --- a/public/intl/messages/sl-SI.json +++ b/public/intl/messages/sl-SI.json @@ -83,12 +83,6 @@ "value": "Povprečno" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Povprečni čas obiska" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Povprečni čas obiska" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/sv-SE.json b/public/intl/messages/sv-SE.json index 45dc754d..28992e10 100644 --- a/public/intl/messages/sv-SE.json +++ b/public/intl/messages/sv-SE.json @@ -83,12 +83,6 @@ "value": "Genomsnitt" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Genomsnittlig besökstid" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Genomsnittlig besökstid" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ta-IN.json b/public/intl/messages/ta-IN.json index b1fcd700..70d7b87e 100644 --- a/public/intl/messages/ta-IN.json +++ b/public/intl/messages/ta-IN.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "சராசரி வருகை நேரம்" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "சராசரி வருகை நேரம்" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/th-TH.json b/public/intl/messages/th-TH.json index 1206840b..351d30f8 100644 --- a/public/intl/messages/th-TH.json +++ b/public/intl/messages/th-TH.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "ระยะเวลาเข้าชมเฉลี่ย" - } - ], "label.back": [ { "type": 0, @@ -1241,6 +1235,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "ระยะเวลาเข้าชมเฉลี่ย" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/tr-TR.json b/public/intl/messages/tr-TR.json index 22442612..c0caf470 100644 --- a/public/intl/messages/tr-TR.json +++ b/public/intl/messages/tr-TR.json @@ -83,12 +83,6 @@ "value": "Ortalama" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Ortalama ziyaret süresi" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Ziyaret başına görüntüleme" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Ortalama ziyaret süresi" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/uk-UA.json b/public/intl/messages/uk-UA.json index ae947f9a..4d693756 100644 --- a/public/intl/messages/uk-UA.json +++ b/public/intl/messages/uk-UA.json @@ -83,12 +83,6 @@ "value": "Середнє" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Середній час візиту" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Перегляди за візит" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Середній час візиту" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/ur-PK.json b/public/intl/messages/ur-PK.json index 184043b4..0316d299 100644 --- a/public/intl/messages/ur-PK.json +++ b/public/intl/messages/ur-PK.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "وزٹ کا اوسط وقت" - } - ], "label.back": [ { "type": 0, @@ -1249,6 +1243,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "وزٹ کا اوسط وقت" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/vi-VN.json b/public/intl/messages/vi-VN.json index fe5943ac..0cc0c183 100644 --- a/public/intl/messages/vi-VN.json +++ b/public/intl/messages/vi-VN.json @@ -83,12 +83,6 @@ "value": "Average" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "Thời gian truy cập trung bình" - } - ], "label.back": [ { "type": 0, @@ -1241,6 +1235,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "Thời gian truy cập trung bình" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json index 144b4f17..e4240022 100644 --- a/public/intl/messages/zh-CN.json +++ b/public/intl/messages/zh-CN.json @@ -83,12 +83,6 @@ "value": "平均" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "平均访问时间" - } - ], "label.back": [ { "type": 0, @@ -1257,6 +1251,12 @@ "value": "每次访问的浏览量" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "平均访问时间" + } + ], "label.visitors": [ { "type": 0, diff --git a/public/intl/messages/zh-TW.json b/public/intl/messages/zh-TW.json index e703beb9..a39aea19 100644 --- a/public/intl/messages/zh-TW.json +++ b/public/intl/messages/zh-TW.json @@ -83,12 +83,6 @@ "value": "平均" } ], - "label.average-visit-time": [ - { - "type": 0, - "value": "平均造訪時間" - } - ], "label.back": [ { "type": 0, @@ -1253,6 +1247,12 @@ "value": "Views per visit" } ], + "label.visit-duration": [ + { + "type": 0, + "value": "平均造訪時間" + } + ], "label.visitors": [ { "type": 0, diff --git a/src/app/(main)/profile/DateRangeSetting.tsx b/src/app/(main)/profile/DateRangeSetting.tsx index c57a209a..25b5afbd 100644 --- a/src/app/(main)/profile/DateRangeSetting.tsx +++ b/src/app/(main)/profile/DateRangeSetting.tsx @@ -7,11 +7,11 @@ import styles from './DateRangeSetting.module.css'; export function DateRangeSetting() { const { formatMessage, labels } = useMessages(); - const [dateRange, setDateRange] = useDateRange(); + const { dateRange, saveDateRange } = useDateRange(); const { value } = dateRange; - const handleChange = (value: string | DateRange) => setDateRange(value); - const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); + const handleChange = (value: string | DateRange) => saveDateRange(value); + const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE); return ( diff --git a/src/app/(main)/reports/goals/GoalsAddForm.tsx b/src/app/(main)/reports/goals/GoalsAddForm.tsx index a8a77c58..a82eea28 100644 --- a/src/app/(main)/reports/goals/GoalsAddForm.tsx +++ b/src/app/(main)/reports/goals/GoalsAddForm.tsx @@ -6,27 +6,48 @@ import styles from './GoalsAddForm.module.css'; export function GoalsAddForm({ type: defaultType = 'url', value: defaultValue = '', + property: defaultProperty = '', + operator: defaultAggregae = null, goal: defaultGoal = 10, onChange, }: { type?: string; value?: string; + operator?: string; + property?: string; goal?: number; - onChange?: (step: { type: string; value: string; goal: number }) => void; + onChange?: (step: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }) => void; }) { const [type, setType] = useState(defaultType); const [value, setValue] = useState(defaultValue); + const [operator, setOperator] = useState(defaultAggregae); + const [property, setProperty] = useState(defaultProperty); const [goal, setGoal] = useState(defaultGoal); const { formatMessage, labels } = useMessages(); const items = [ { label: formatMessage(labels.url), value: 'url' }, { label: formatMessage(labels.event), value: 'event' }, + { label: formatMessage(labels.eventData), value: 'event-data' }, + ]; + const operators = [ + { label: formatMessage(labels.count), value: 'count' }, + { label: formatMessage(labels.average), value: 'average' }, + { label: formatMessage(labels.sum), value: 'sum' }, ]; const isDisabled = !type || !value; const handleSave = () => { - onChange({ type, value, goal }); + onChange( + type === 'event-data' ? { type, value, goal, operator, property } : { type, value, goal }, + ); setValue(''); + setProperty(''); setGoal(10); }; @@ -45,6 +66,10 @@ export function GoalsAddForm({ return items.find(item => item.value === value)?.label; }; + const renderoperatorValue = (value: any) => { + return operators.find(item => item.value === value)?.label; + }; + return ( @@ -70,6 +95,31 @@ export function GoalsAddForm({ /> + {type === 'event-data' && ( + + + setOperator(value)} + > + {({ value, label }) => { + return {label}; + }} + + handleChange(e, setProperty)} + autoFocus={true} + autoComplete="off" + onKeyDown={handleKeyDown} + /> + + + )} { + let label = ''; + switch (type) { + case 'url': + label = labels.viewedPage; + break; + case 'event': + label = labels.triggeredEvent; + break; + default: + label = labels.collectedData; + break; + } + + return label; + }; + return (
- {data?.map(({ type, value, goal, result }, index: number) => { + {data?.map(({ type, value, goal, result, property, operator }, index: number) => { const percent = result > goal ? 100 : (result / goal) * 100; return (
- - {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)} - - {value} + {formatMessage(getLabel(type))} + {`${value}${ + type === 'event-data' ? `:(${operator}):${property}` : '' + }`}
}> - {goals.map((goal: { type: string; value: string; goal: number }, index: number) => { - return ( - - : } - onRemove={() => handleRemoveGoals(index)} - > -
{goal.value}
-
- {formatMessage(labels.goal)}: {formatNumber(goal.goal)} -
-
- - {(close: () => void) => ( - - - - )} - -
- ); - })} + {goals.map( + ( + goal: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }, + index: number, + ) => { + return ( + + : } + onRemove={() => handleRemoveGoals(index)} + > + +
{goal.value}
+ {goal.type === 'event-data' && ( +
+ {formatMessage(labels[goal.operator])}: {goal.property} +
+ )} +
+ {formatMessage(labels.goal)}: {formatNumber(goal.goal)} +
+
+
+ + {(close: () => void) => ( + + + + )} + +
+ ); + }, + )}
diff --git a/src/app/(main)/reports/journey/JourneyParameters.tsx b/src/app/(main)/reports/journey/JourneyParameters.tsx new file mode 100644 index 00000000..b0544168 --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyParameters.tsx @@ -0,0 +1,36 @@ +import { useContext } from 'react'; +import { useMessages } from 'components/hooks'; +import { Form, FormButtons, SubmitButton } from 'react-basics'; +import { ReportContext } from '../[reportId]/Report'; +import BaseParameters from '../[reportId]/BaseParameters'; + +export function JourneyParameters() { + const { report, runReport, isRunning } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + + const { id, parameters } = report || {}; + const { websiteId, dateRange } = parameters || {}; + const queryDisabled = !websiteId || !dateRange; + + const handleSubmit = (data: any, e: any) => { + e.stopPropagation(); + e.preventDefault(); + + if (!queryDisabled) { + runReport(data); + } + }; + + return ( +
+ + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default JourneyParameters; diff --git a/src/app/(main)/reports/journey/JourneyReport.tsx b/src/app/(main)/reports/journey/JourneyReport.tsx new file mode 100644 index 00000000..7b8927b4 --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyReport.tsx @@ -0,0 +1,28 @@ +'use client'; +import Report from '../[reportId]/Report'; +import ReportHeader from '../[reportId]/ReportHeader'; +import ReportMenu from '../[reportId]/ReportMenu'; +import ReportBody from '../[reportId]/ReportBody'; +import JourneyParameters from './JourneyParameters'; +import JourneyView from './JourneyView'; +import Path from 'assets/path.svg'; +import { REPORT_TYPES } from 'lib/constants'; + +const defaultParameters = { + type: REPORT_TYPES.journey, + parameters: {}, +}; + +export default function JourneyReport({ reportId }: { reportId?: string }) { + return ( + + } /> + + + + + + + + ); +} diff --git a/src/app/(main)/reports/journey/JourneyReportPage.tsx b/src/app/(main)/reports/journey/JourneyReportPage.tsx new file mode 100644 index 00000000..0f4b78ca --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyReportPage.tsx @@ -0,0 +1,5 @@ +import JourneyReport from './JourneyReport'; + +export default function JourneyReportPage() { + return ; +} diff --git a/src/app/(main)/reports/journey/JourneyView.module.css b/src/app/(main)/reports/journey/JourneyView.module.css new file mode 100644 index 00000000..fa7cc0b4 --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyView.module.css @@ -0,0 +1,14 @@ +.title { + font-size: 24px; + line-height: 36px; + font-weight: 700; +} + +.row { + display: grid; + grid-template-columns: 50% 50%; + gap: 20px; + border-bottom: 1px solid var(--base300); + padding-bottom: 30px; + margin-bottom: 30px; +} diff --git a/src/app/(main)/reports/journey/JourneyView.tsx b/src/app/(main)/reports/journey/JourneyView.tsx new file mode 100644 index 00000000..6905d74c --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyView.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { ReportContext } from '../[reportId]/Report'; + +export default function JourneyView() { + const { report } = useContext(ReportContext); + const { data } = report || {}; + + if (!data) { + return null; + } + + return
{JSON.stringify(data)}
; +} diff --git a/src/app/(main)/reports/journey/page.tsx b/src/app/(main)/reports/journey/page.tsx new file mode 100644 index 00000000..447747cc --- /dev/null +++ b/src/app/(main)/reports/journey/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from 'next'; +import JourneyReportPage from './JourneyReportPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Journey Report', +}; diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx index e59b60eb..f10a68d8 100644 --- a/src/app/(main)/reports/utm/UTMView.tsx +++ b/src/app/(main)/reports/utm/UTMView.tsx @@ -34,6 +34,7 @@ export default function UTMView() { { data: items.map(({ value }) => value), backgroundColor: CHART_COLORS, + borderWidth: 0, }, ], }; diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx index 1a92f1f1..11f662b1 100644 --- a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx +++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx @@ -61,7 +61,7 @@ export function WebsiteSettings({ {tab === 'details' && } {tab === 'tracking' && } - {tab === 'share' && } + {tab === 'share' && } {tab === 'data' && } ); diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index 470bd792..48da2377 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -4,17 +4,41 @@ import { getDateArray } from 'lib/date'; import useWebsitePageviews from 'components/hooks/queries/useWebsitePageviews'; import { useDateRange } from 'components/hooks'; -export function WebsiteChart({ websiteId }: { websiteId: string }) { - const [dateRange] = useDateRange(websiteId); +export function WebsiteChart({ + websiteId, + compareMode = false, +}: { + websiteId: string; + compareMode?: boolean; +}) { + const { dateRange, dateCompare } = useDateRange(websiteId); const { startDate, endDate, unit } = dateRange; - const { data, isLoading } = useWebsitePageviews(websiteId); + const { data, isLoading } = useWebsitePageviews(websiteId, compareMode ? dateCompare : undefined); + const { pageviews, sessions, compare } = (data || {}) as any; const chartData = useMemo(() => { if (data) { - return { - pageviews: getDateArray(data.pageviews, startDate, endDate, unit), - sessions: getDateArray(data.sessions, startDate, endDate, unit), + const result = { + pageviews: getDateArray(pageviews, startDate, endDate, unit), + sessions: getDateArray(sessions, startDate, endDate, unit), }; + + if (compare) { + result['compare'] = { + pageviews: result.pageviews.map(({ x }, i) => ({ + x, + y: compare.pageviews[i]?.y, + d: compare.pageviews[i]?.x, + })), + sessions: result.sessions.map(({ x }, i) => ({ + x, + y: compare.sessions[i]?.y, + d: compare.sessions[i]?.x, + })), + }; + } + + return result; } return { pageviews: [], sessions: [] }; }, [data, startDate, endDate, unit]); diff --git a/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx b/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx deleted file mode 100644 index 1a131da1..00000000 --- a/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; -import { Loading } from 'react-basics'; -import { usePathname } from 'next/navigation'; -import Page from 'components/layout/Page'; -import FilterTags from 'components/metrics/FilterTags'; -import { useNavigation, useWebsite } from 'components/hooks'; -import WebsiteChart from './WebsiteChart'; -import WebsiteExpandedView from './WebsiteExpandedView'; -import WebsiteHeader from './WebsiteHeader'; -import WebsiteMetricsBar from './WebsiteMetricsBar'; -import WebsiteTableView from './WebsiteTableView'; - -export default function WebsiteDetails({ websiteId }: { websiteId: string }) { - const { data: website, isLoading, error } = useWebsite(websiteId); - const pathname = usePathname(); - const { query } = useNavigation(); - - if (isLoading || error) { - return ; - } - - const showLinks = !pathname.includes('/share/'); - const { view, ...params } = query; - - return ( - <> - - - - - {!website && } - {website && ( - <> - {!view && } - {view && } - - )} - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx new file mode 100644 index 00000000..2988494e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx @@ -0,0 +1,37 @@ +'use client'; +import { usePathname } from 'next/navigation'; +import FilterTags from 'components/metrics/FilterTags'; +import { useNavigation } from 'components/hooks'; +import WebsiteChart from './WebsiteChart'; +import WebsiteExpandedView from './WebsiteExpandedView'; +import WebsiteHeader from './WebsiteHeader'; +import WebsiteMetricsBar from './WebsiteMetricsBar'; +import WebsiteTableView from './WebsiteTableView'; +import WebsiteProvider from './WebsiteProvider'; +import { FILTER_COLUMNS } from 'lib/constants'; + +export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) { + const pathname = usePathname(); + const { query } = useNavigation(); + + const showLinks = !pathname.includes('/share/'); + const { view } = query; + + const params = Object.keys(query).reduce((obj, key) => { + if (FILTER_COLUMNS[key]) { + obj[key] = query[key]; + } + return obj; + }, {}); + + return ( + + + + + + {!view && } + {view && } + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx index a9671757..a6229e95 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import { Button, Icon, Icons, Popup, PopupTrigger, Text } from 'react-basics'; import PopupForm from 'app/(main)/reports/[reportId]/PopupForm'; import FilterSelectForm from 'app/(main)/reports/[reportId]/FilterSelectForm'; @@ -9,14 +8,22 @@ import styles from './WebsiteFilterButton.module.css'; export function WebsiteFilterButton({ websiteId, className, + position = 'bottom', + alignment = 'end', + showText = true, }: { websiteId: string; className?: string; + position?: 'bottom' | 'top' | 'left' | 'right'; + alignment?: 'end' | 'center' | 'start'; + showText?: boolean; }) { const { formatMessage, labels } = useMessages(); const { renderUrl, router } = useNavigation(); const { fields } = useFields(); - const [{ startDate, endDate }] = useDateRange(websiteId); + const { + dateRange: { startDate, endDate }, + } = useDateRange(websiteId); const handleAddFilter = ({ name, operator, value }) => { const prefix = OPERATOR_PREFIXES[operator]; @@ -25,14 +32,14 @@ export function WebsiteFilterButton({ }; return ( - - - + {(close: () => void) => { return ( diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx index dc0f4338..0cbaeb44 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx @@ -30,6 +30,11 @@ export function WebsiteHeader({ icon: , path: '', }, + { + label: formatMessage(labels.compare), + icon: , + path: '/compare', + }, { label: formatMessage(labels.realtime), icon: , diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.module.css b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.module.css index db48bd55..6c5a0e56 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.module.css +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.module.css @@ -1,6 +1,6 @@ .container { display: grid; - grid-template-columns: 1fr max-content; + grid-template-columns: 2fr 1fr; justify-content: space-between; align-items: center; background: var(--base50); @@ -11,10 +11,22 @@ .actions { display: flex; - align-items: center; - flex-direction: row; - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; gap: 10px; + flex-wrap: wrap; +} + +.vs { + display: flex; + align-items: center; + justify-content: flex-end; + flex-basis: 100%; + gap: 10px; +} + +.dropdown { + min-width: 200px; } @media screen and (max-width: 1200px) { @@ -38,9 +50,3 @@ border-bottom: 1px solid var(--base300); } } - -@media screen and (max-width: 768px) { - .button { - display: none; - } -} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx index e4acea3b..b74482a4 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -3,94 +3,127 @@ import { useMessages, useSticky } from 'components/hooks'; import WebsiteDateFilter from 'components/input/WebsiteDateFilter'; import MetricCard from 'components/metrics/MetricCard'; import MetricsBar from 'components/metrics/MetricsBar'; -import { formatShortTime } from 'lib/format'; +import { formatShortTime, formatLongNumber } from 'lib/format'; import WebsiteFilterButton from './WebsiteFilterButton'; -import styles from './WebsiteMetricsBar.module.css'; import useWebsiteStats from 'components/hooks/queries/useWebsiteStats'; +import styles from './WebsiteMetricsBar.module.css'; +import { Dropdown, Item } from 'react-basics'; +import useStore, { setWebsiteDateCompare } from 'store/websites'; export function WebsiteMetricsBar({ websiteId, - showFilter = true, sticky, + showChange = false, + compareMode = false, }: { websiteId: string; - showFilter?: boolean; sticky?: boolean; + showChange?: boolean; + compareMode?: boolean; }) { const { formatMessage, labels } = useMessages(); + const dateCompare = useStore(state => state[websiteId]?.dateCompare); const { ref, isSticky } = useSticky({ enabled: sticky }); - const { data, isLoading, isFetched, error } = useWebsiteStats(websiteId); + const { data, isLoading, isFetched, error } = useWebsiteStats( + websiteId, + compareMode && dateCompare, + ); const { pageviews, visitors, visits, bounces, totaltime } = data || {}; - const num = Math.min(data && visitors.value, data && bounces.value); - const diffs = data && { - pageviews: pageviews.value - pageviews.change, - visitors: visitors.value - visitors.change, - visits: visits.value - visits.change, - bounces: bounces.value - bounces.change, - totaltime: totaltime.value - totaltime.change, - }; + + const metrics = data + ? [ + { + ...pageviews, + label: formatMessage(labels.views), + change: pageviews.value - pageviews.prev, + formatValue: formatLongNumber, + }, + { + ...visits, + label: formatMessage(labels.visits), + change: visits.value - visits.prev, + formatValue: formatLongNumber, + }, + { + ...visitors, + label: formatMessage(labels.visitors), + change: visitors.value - visitors.prev, + formatValue: formatLongNumber, + }, + { + label: formatMessage(labels.bounceRate), + value: (Math.min(visitors.value, bounces.value) / visitors.value) * 100, + prev: (Math.min(visitors.prev, bounces.prev) / visitors.prev) * 100, + change: + (Math.min(visitors.value, bounces.value) / visitors.value) * 100 - + (Math.min(visitors.prev, bounces.prev) / visitors.prev) * 100, + formatValue: n => Number(n).toFixed(0) + '%', + reverseColors: true, + }, + { + label: formatMessage(labels.visitDuration), + value: totaltime.value / visits.value, + prev: totaltime.prev / visits.prev, + change: totaltime.value / visits.value - totaltime.prev / visits.prev, + formatValue: n => + `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`, + }, + ] + : []; + + const items = [ + { label: formatMessage(labels.previousPeriod), value: 'prev' }, + { label: formatMessage(labels.previousYear), value: 'yoy' }, + ]; return (
- - {pageviews && visitors && ( - <> - - - - Number(n).toFixed(0) + '%'} - reverseColors - /> - `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`} - /> - - )} - +
+ + {metrics.map(({ label, value, prev, change, formatValue, reverseColors }) => { + return ( + + ); + })} + +
- {showFilter && } + + {compareMode && ( +
+ VS + items.find(i => i.value === value)?.label} + alignment="end" + onChange={(value: any) => setWebsiteDateCompare(websiteId, value)} + > + {items.map(({ label, value }) => ( + {label} + ))} + +
+ )}
); diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx index 7cc415e5..e530f2ba 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx @@ -10,17 +10,10 @@ import CountriesTable from 'components/metrics/CountriesTable'; import EventsTable from 'components/metrics/EventsTable'; import EventsChart from 'components/metrics/EventsChart'; -export default function WebsiteTableView({ - websiteId, - domainName, -}: { - websiteId: string; - domainName: string; -}) { +export default function WebsiteTableView({ websiteId }: { websiteId: string }) { const [countryData, setCountryData] = useState(); const tableProps = { websiteId, - domainName, limit: 10, }; diff --git a/src/app/(main)/websites/[websiteId]/compare/WebsiteComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/WebsiteComparePage.tsx new file mode 100644 index 00000000..092ebe94 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/WebsiteComparePage.tsx @@ -0,0 +1,32 @@ +'use client'; +import WebsiteHeader from '../WebsiteHeader'; +import WebsiteMetricsBar from '../WebsiteMetricsBar'; +import FilterTags from 'components/metrics/FilterTags'; +import { useNavigation } from 'components/hooks'; +import { FILTER_COLUMNS } from 'lib/constants'; +import WebsiteChart from '../WebsiteChart'; +import WebsiteCompareTables from './WebsiteCompareTables'; +import WebsiteProvider from '../WebsiteProvider'; + +export function WebsiteComparePage({ websiteId }) { + const { query } = useNavigation(); + + const params = Object.keys(query).reduce((obj, key) => { + if (FILTER_COLUMNS[key]) { + obj[key] = query[key]; + } + return obj; + }, {}); + + return ( + + + + + + + + ); +} + +export default WebsiteComparePage; diff --git a/src/app/(main)/websites/[websiteId]/compare/WebsiteCompareTables.module.css b/src/app/(main)/websites/[websiteId]/compare/WebsiteCompareTables.module.css new file mode 100644 index 00000000..c4821e88 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/WebsiteCompareTables.module.css @@ -0,0 +1,14 @@ +.container { + margin-bottom: 60px; +} + +.nav { + width: 200px; + margin-top: 40px; +} + +.title { + color: var(--base800); + text-align: center; + font-weight: 700; +} diff --git a/src/app/(main)/websites/[websiteId]/compare/WebsiteCompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/WebsiteCompareTables.tsx new file mode 100644 index 00000000..1b21103d --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/WebsiteCompareTables.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import SideNav from 'components/layout/SideNav'; +import { useDateRange, useMessages, useNavigation } from 'components/hooks'; +import PagesTable from 'components/metrics/PagesTable'; +import ReferrersTable from 'components/metrics/ReferrersTable'; +import BrowsersTable from 'components/metrics/BrowsersTable'; +import OSTable from 'components/metrics/OSTable'; +import DevicesTable from 'components/metrics/DevicesTable'; +import ScreenTable from 'components/metrics/ScreenTable'; +import CountriesTable from 'components/metrics/CountriesTable'; +import RegionsTable from 'components/metrics/RegionsTable'; +import CitiesTable from 'components/metrics/CitiesTable'; +import LanguagesTable from 'components/metrics/LanguagesTable'; +import EventsTable from 'components/metrics/EventsTable'; +import QueryParametersTable from 'components/metrics/QueryParametersTable'; +import { Grid, GridRow } from 'components/layout/Grid'; +import MetricsTable from 'components/metrics/MetricsTable'; +import useStore from 'store/websites'; +import { getCompareDate } from 'lib/date'; +import { formatNumber } from 'lib/format'; +import ChangeLabel from 'components/metrics/ChangeLabel'; +import styles from './WebsiteCompareTables.module.css'; + +const views = { + url: PagesTable, + title: PagesTable, + referrer: ReferrersTable, + browser: BrowsersTable, + os: OSTable, + device: DevicesTable, + screen: ScreenTable, + country: CountriesTable, + region: RegionsTable, + city: CitiesTable, + language: LanguagesTable, + event: EventsTable, + query: QueryParametersTable, +}; + +export function WebsiteCompareTables({ websiteId }: { websiteId: string }) { + const [data, setData] = useState([]); + const { dateRange } = useDateRange(websiteId); + const dateCompare = useStore(state => state[websiteId]?.dateCompare); + const { formatMessage, labels } = useMessages(); + const { + renderUrl, + query: { view }, + } = useNavigation(); + const Component: typeof MetricsTable = views[view || 'url'] || (() => null); + + const items = [ + { + key: 'url', + label: formatMessage(labels.pages), + url: renderUrl({ view: 'url' }), + }, + { + key: 'referrer', + label: formatMessage(labels.referrers), + url: renderUrl({ view: 'referrer' }), + }, + { + key: 'browser', + label: formatMessage(labels.browsers), + url: renderUrl({ view: 'browser' }), + }, + { + key: 'os', + label: formatMessage(labels.os), + url: renderUrl({ view: 'os' }), + }, + { + key: 'device', + label: formatMessage(labels.devices), + url: renderUrl({ view: 'device' }), + }, + { + key: 'country', + label: formatMessage(labels.countries), + url: renderUrl({ view: 'country' }), + }, + { + key: 'region', + label: formatMessage(labels.regions), + url: renderUrl({ view: 'region' }), + }, + { + key: 'city', + label: formatMessage(labels.cities), + url: renderUrl({ view: 'city' }), + }, + { + key: 'language', + label: formatMessage(labels.languages), + url: renderUrl({ view: 'language' }), + }, + { + key: 'screen', + label: formatMessage(labels.screens), + url: renderUrl({ view: 'screen' }), + }, + { + key: 'event', + label: formatMessage(labels.events), + url: renderUrl({ view: 'event' }), + }, + { + key: 'query', + label: formatMessage(labels.queryParameters), + url: renderUrl({ view: 'query' }), + }, + ]; + + const renderChange = ({ x, y }) => { + const prev = data.find(d => d.x === x)?.y; + const value = y - prev; + const change = Math.abs(((y - prev) / prev) * 100); + + return !isNaN(change) && {formatNumber(change)}%; + }; + + const { startDate, endDate } = getCompareDate( + dateCompare, + dateRange.startDate, + dateRange.endDate, + ); + + const params = { + startAt: startDate.getTime(), + endAt: endDate.getTime(), + }; + + return ( + + + +
+
{formatMessage(labels.previous)}
+ +
+
+
{formatMessage(labels.current)}
+ +
+
+
+ ); +} + +export default WebsiteCompareTables; diff --git a/src/app/(main)/websites/[websiteId]/compare/page.tsx b/src/app/(main)/websites/[websiteId]/compare/page.tsx new file mode 100644 index 00000000..b3009fca --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/page.tsx @@ -0,0 +1,10 @@ +import WebsiteComparePage from './WebsiteComparePage'; +import { Metadata } from 'next'; + +export default function ({ params: { websiteId } }) { + return ; +} + +export const metadata: Metadata = { + title: 'Website Comparison', +}; diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataMetricsBar.tsx index fceb86aa..86417c96 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataMetricsBar.tsx @@ -7,7 +7,7 @@ import styles from './EventDataMetricsBar.module.css'; export function EventDataMetricsBar({ websiteId }: { websiteId: string }) { const { formatMessage, labels } = useMessages(); const { get, useQuery } = useApi(); - const [dateRange] = useDateRange(websiteId); + const { dateRange } = useDateRange(websiteId); const { startDate, endDate } = dateRange; const { data, error, isLoading, isFetched } = useQuery({ diff --git a/src/app/(main)/websites/[websiteId]/event-data/WebsiteEventData.tsx b/src/app/(main)/websites/[websiteId]/event-data/WebsiteEventData.tsx index 27ccd96c..d7d24cee 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/WebsiteEventData.tsx +++ b/src/app/(main)/websites/[websiteId]/event-data/WebsiteEventData.tsx @@ -6,7 +6,7 @@ import { useDateRange, useApi, useNavigation } from 'components/hooks'; import styles from './WebsiteEventData.module.css'; function useData(websiteId: string, event: string) { - const [dateRange] = useDateRange(websiteId); + const { dateRange } = useDateRange(websiteId); const { startDate, endDate } = dateRange; const { get, useQuery } = useApi(); const { data, error, isLoading } = useQuery({ diff --git a/src/app/(main)/websites/[websiteId]/page.tsx b/src/app/(main)/websites/[websiteId]/page.tsx index ddb6c833..49bca9b6 100644 --- a/src/app/(main)/websites/[websiteId]/page.tsx +++ b/src/app/(main)/websites/[websiteId]/page.tsx @@ -1,8 +1,8 @@ -import WebsiteDetails from './WebsiteDetails'; +import WebsiteDetailsPage from './WebsiteDetailsPage'; import { Metadata } from 'next'; export default function WebsitePage({ params: { websiteId } }) { - return ; + return ; } export const metadata: Metadata = { diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx index 80d3d8c6..cef6e742 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx @@ -14,25 +14,25 @@ export function RealtimeHeader({ data }: { data: RealtimeData }) { className={styles.card} label={formatMessage(labels.views)} value={pageviews?.length} - hideComparison + showChange />
diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx index b49d36ec..4ac6af37 100644 --- a/src/app/share/[...shareId]/SharePage.tsx +++ b/src/app/share/[...shareId]/SharePage.tsx @@ -1,5 +1,5 @@ 'use client'; -import WebsiteDetails from 'app/(main)/websites/[websiteId]/WebsiteDetails'; +import WebsiteDetailsPage from '../../(main)/websites/[websiteId]/WebsiteDetailsPage'; import { useShareToken } from 'components/hooks'; import Page from 'components/layout/Page'; import Header from './Header'; @@ -17,7 +17,7 @@ export default function SharePage({ shareId }) {
- +
diff --git a/src/assets/compare.svg b/src/assets/compare.svg new file mode 100644 index 00000000..e037c243 --- /dev/null +++ b/src/assets/compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logo-white.svg b/src/assets/logo-white.svg new file mode 100644 index 00000000..12a76d34 --- /dev/null +++ b/src/assets/logo-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/path.svg b/src/assets/path.svg new file mode 100644 index 00000000..29501565 --- /dev/null +++ b/src/assets/path.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/target.svg b/src/assets/target.svg index 000f34c7..c2e47e32 100644 --- a/src/assets/target.svg +++ b/src/assets/target.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx index 635bb10d..cfcbe743 100644 --- a/src/components/charts/BarChart.tsx +++ b/src/components/charts/BarChart.tsx @@ -26,7 +26,7 @@ export function BarChart(props: BarChartProps) { stacked = false, } = props; - const options = useMemo(() => { + const options: any = useMemo(() => { return { scales: { x: { diff --git a/src/components/charts/BarChartTooltip.tsx b/src/components/charts/BarChartTooltip.tsx index b81d55fe..fed5af92 100644 --- a/src/components/charts/BarChartTooltip.tsx +++ b/src/components/charts/BarChartTooltip.tsx @@ -21,7 +21,9 @@ export default function BarChartTooltip({ tooltip, unit }) { return ( -
{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}
+
+ {formatDate(new Date(dataPoints[0].raw.d || dataPoints[0].raw.x), formats[unit], locale)} +
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label} diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx index 40829cac..6ba60159 100644 --- a/src/components/charts/Chart.tsx +++ b/src/components/charts/Chart.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useMemo, ReactNode } from 'react'; import { Loading } from 'react-basics'; import classNames from 'classnames'; -import ChartJS, { LegendItem } from 'chart.js/auto'; +import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto'; import HoverTooltip from 'components/common/HoverTooltip'; import Legend from 'components/metrics/Legend'; import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; @@ -17,7 +17,7 @@ export interface ChartProps { onUpdate?: (chart: any) => void; onTooltip?: (model: any) => void; className?: string; - chartOptions?: { [key: string]: any }; + chartOptions?: ChartOptions; tooltip?: ReactNode; } @@ -79,24 +79,28 @@ export function Chart({ }; const updateChart = (data: any) => { - chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => { - if (data?.datasets[index]) { - dataset.data = data?.datasets[index]?.data; + if (data.datasets.length === chart.current.data.datasets.length) { + chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => { + if (data?.datasets[index]) { + dataset.data = data?.datasets[index]?.data; - if (chart.current.legend.legendItems[index]) { - chart.current.legend.legendItems[index].text = data?.datasets[index]?.label; + if (chart.current.legend.legendItems[index]) { + chart.current.legend.legendItems[index].text = data?.datasets[index]?.label; + } } - } - }); + }); + } else { + chart.current.data.datasets = data.datasets; + } chart.current.options = options; // Allow config changes before update onUpdate?.(chart.current); - setLegendItems(chart.current.legend.legendItems); - chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); }; useEffect(() => { diff --git a/src/components/hooks/queries/useWebsiteMetrics.ts b/src/components/hooks/queries/useWebsiteMetrics.ts index dd201155..088b31ac 100644 --- a/src/components/hooks/queries/useWebsiteMetrics.ts +++ b/src/components/hooks/queries/useWebsiteMetrics.ts @@ -4,7 +4,7 @@ import { useFilterParams } from '../useFilterParams'; export function useWebsiteMetrics( websiteId: string, - query: { type: string; limit: number; search: string }, + queryParams: { type: string; limit: number; search: string; startAt?: number; endAt?: number }, options?: Omit void }, 'queryKey' | 'queryFn'>, ) { const { get, useQuery } = useApi(); @@ -16,17 +16,17 @@ export function useWebsiteMetrics( { websiteId, ...params, - ...query, + ...queryParams, }, ], queryFn: async () => { const filters = { ...params }; - filters[query.type] = undefined; + filters[queryParams.type] = undefined; const data = await get(`/websites/${websiteId}/metrics`, { ...filters, - ...query, + ...queryParams, }); options?.onDataLoad?.(data); diff --git a/src/components/hooks/queries/useWebsitePageviews.ts b/src/components/hooks/queries/useWebsitePageviews.ts index 0db1fe63..c9260bcb 100644 --- a/src/components/hooks/queries/useWebsitePageviews.ts +++ b/src/components/hooks/queries/useWebsitePageviews.ts @@ -4,14 +4,15 @@ import { useFilterParams } from '..//useFilterParams'; export function useWebsitePageviews( websiteId: string, + compare?: string, options?: Omit, ) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); return useQuery({ - queryKey: ['websites:pageviews', { websiteId, ...params }], - queryFn: () => get(`/websites/${websiteId}/pageviews`, params), + queryKey: ['websites:pageviews', { websiteId, ...params, compare }], + queryFn: () => get(`/websites/${websiteId}/pageviews`, { ...params, compare }), enabled: !!websiteId, ...options, }); diff --git a/src/components/hooks/queries/useWebsiteStats.ts b/src/components/hooks/queries/useWebsiteStats.ts index c2c4b74f..b24399fa 100644 --- a/src/components/hooks/queries/useWebsiteStats.ts +++ b/src/components/hooks/queries/useWebsiteStats.ts @@ -1,13 +1,17 @@ import { useApi } from './useApi'; import { useFilterParams } from '../useFilterParams'; -export function useWebsiteStats(websiteId: string, options?: { [key: string]: string }) { +export function useWebsiteStats( + websiteId: string, + compare?: string, + options?: { [key: string]: string }, +) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); return useQuery({ - queryKey: ['websites:stats', { websiteId, ...params }], - queryFn: () => get(`/websites/${websiteId}/stats`, params), + queryKey: ['websites:stats', { websiteId, ...params, compare }], + queryFn: () => get(`/websites/${websiteId}/stats`, { ...params, compare }), enabled: !!websiteId, ...options, }); diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts index e022d960..248070f4 100644 --- a/src/components/hooks/useDateRange.ts +++ b/src/components/hooks/useDateRange.ts @@ -1,19 +1,25 @@ import { getMinimumUnit, parseDateRange } from 'lib/date'; import { setItem } from 'next-basics'; -import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants'; -import websiteStore, { setWebsiteDateRange } from 'store/websites'; +import { DATE_RANGE_CONFIG, DEFAULT_DATE_COMPARE, DEFAULT_DATE_RANGE } from 'lib/constants'; +import websiteStore, { setWebsiteDateRange, setWebsiteDateCompare } from 'store/websites'; import appStore, { setDateRange } from 'store/app'; import { DateRange } from 'lib/types'; import { useLocale } from './useLocale'; import { useApi } from './queries/useApi'; -export function useDateRange(websiteId?: string): [DateRange, (value: string | DateRange) => void] { +export function useDateRange(websiteId?: string): { + dateRange: DateRange; + saveDateRange: (value: string | DateRange) => void; + dateCompare: string; + saveDateCompare: (value: string) => void; +} { const { get } = useApi(); const { locale } = useLocale(); const websiteConfig = websiteStore(state => state[websiteId]?.dateRange); const defaultConfig = DEFAULT_DATE_RANGE; const globalConfig = appStore(state => state.dateRange); const dateRange = parseDateRange(websiteConfig || globalConfig || defaultConfig, locale); + const dateCompare = websiteStore(state => state[websiteId]?.dateCompare || DEFAULT_DATE_COMPARE); const saveDateRange = async (value: DateRange | string) => { if (websiteId) { @@ -45,7 +51,11 @@ export function useDateRange(websiteId?: string): [DateRange, (value: string | D } }; - return [dateRange, saveDateRange]; + const saveDateCompare = (value: string) => { + setWebsiteDateCompare(websiteId, value); + }; + + return { dateRange, saveDateRange, dateCompare, saveDateCompare }; } export default useDateRange; diff --git a/src/components/hooks/useFilterParams.ts b/src/components/hooks/useFilterParams.ts index fd76fd47..ac934d8c 100644 --- a/src/components/hooks/useFilterParams.ts +++ b/src/components/hooks/useFilterParams.ts @@ -4,7 +4,7 @@ import { useTimezone } from './useTimezone'; import { zonedTimeToUtc } from 'date-fns-tz'; export function useFilterParams(websiteId: string) { - const [dateRange] = useDateRange(websiteId); + const { dateRange } = useDateRange(websiteId); const { startDate, endDate, unit } = dateRange; const { timezone } = useTimezone(); const { diff --git a/src/components/icons.ts b/src/components/icons.ts index 3cbb09d2..8e5a481c 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -6,6 +6,7 @@ import Bolt from 'assets/bolt.svg'; import Calendar from 'assets/calendar.svg'; import Change from 'assets/change.svg'; import Clock from 'assets/clock.svg'; +import Compare from 'assets/compare.svg'; import Dashboard from 'assets/dashboard.svg'; import Eye from 'assets/eye.svg'; import Gear from 'assets/gear.svg'; @@ -32,6 +33,7 @@ const icons = { Calendar, Change, Clock, + Compare, Dashboard, Eye, Gear, diff --git a/src/components/input/RefreshButton.tsx b/src/components/input/RefreshButton.tsx index 4515a574..cd68c40a 100644 --- a/src/components/input/RefreshButton.tsx +++ b/src/components/input/RefreshButton.tsx @@ -12,7 +12,7 @@ export function RefreshButton({ isLoading?: boolean; }) { const { formatMessage, labels } = useMessages(); - const [dateRange] = useDateRange(websiteId); + const { dateRange } = useDateRange(websiteId); function handleClick() { if (!isLoading && dateRange) { diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx index 3cb88766..4f2dee0a 100644 --- a/src/components/input/WebsiteDateFilter.tsx +++ b/src/components/input/WebsiteDateFilter.tsx @@ -8,21 +8,30 @@ import { DateRange } from 'lib/types'; export function WebsiteDateFilter({ websiteId }: { websiteId: string }) { const { dir } = useLocale(); - const [dateRange, setDateRange] = useDateRange(websiteId); + const { dateRange, saveDateRange } = useDateRange(websiteId); const { value, startDate, endDate, offset } = dateRange; const disableForward = value === 'all' || isAfter(getOffsetDateRange(dateRange, 1).startDate, new Date()); const handleChange = (value: string | DateRange) => { - setDateRange(value); + saveDateRange(value); }; const handleIncrement = (increment: number) => { - setDateRange(getOffsetDateRange(dateRange, increment)); + saveDateRange(getOffsetDateRange(dateRange, increment)); }; return (
+ {value !== 'all' && !value.startsWith('range') && (
)} -
); } diff --git a/src/components/layout/Grid.module.css b/src/components/layout/Grid.module.css index f72a5f12..de99b752 100644 --- a/src/components/layout/Grid.module.css +++ b/src/components/layout/Grid.module.css @@ -8,6 +8,10 @@ border-top: 1px solid var(--base300); } +.row.compare { + grid-template-columns: max-content 1fr 1fr; +} + .col { padding: 20px; min-height: 430px; diff --git a/src/components/layout/Grid.tsx b/src/components/layout/Grid.tsx index 2a34fdc4..ec7f4fda 100644 --- a/src/components/layout/Grid.tsx +++ b/src/components/layout/Grid.tsx @@ -1,6 +1,7 @@ import { CSSProperties } from 'react'; import classNames from 'classnames'; import { mapChildren } from 'react-basics'; +// eslint-disable-next-line css-modules/no-unused-class import styles from './Grid.module.css'; export interface GridProps { @@ -19,13 +20,13 @@ export function Grid({ className, style, children }: GridProps) { export function GridRow(props: { [x: string]: any; - columns?: 'one' | 'two' | 'three' | 'one-two' | 'two-one'; + columns?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare'; className?: string; children?: any; }) { const { columns = 'two', className, children, ...otherProps } = props; return ( -
+
{mapChildren(children, child => { return
{child}
; })} diff --git a/src/components/messages.ts b/src/components/messages.ts index 1413549f..53e69401 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -95,6 +95,9 @@ export const labels = defineMessages({ devices: { id: 'label.devices', defaultMessage: 'Devices' }, countries: { id: 'label.countries', defaultMessage: 'Countries' }, languages: { id: 'label.languages', defaultMessage: 'Languages' }, + count: { id: 'label.count', defaultMessage: 'Count' }, + average: { id: 'label.average', defaultMessage: 'Average' }, + sum: { id: 'label.sum', defaultMessage: 'Sum' }, event: { id: 'label.event', defaultMessage: 'Event' }, events: { id: 'label.events', defaultMessage: 'Events' }, query: { id: 'label.query', defaultMessage: 'Query' }, @@ -107,6 +110,7 @@ export const labels = defineMessages({ views: { id: 'label.views', defaultMessage: 'Views' }, none: { id: 'label.none', defaultMessage: 'None' }, clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' }, + property: { id: 'label.property', defaultMessage: 'Property' }, today: { id: 'label.today', defaultMessage: 'Today' }, lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' }, yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' }, @@ -130,7 +134,7 @@ export const labels = defineMessages({ uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' }, bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' }, viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' }, - averageVisitTime: { id: 'label.average-visit-time', defaultMessage: 'Average visit time' }, + visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' }, desktop: { id: 'label.desktop', defaultMessage: 'Desktop' }, laptop: { id: 'label.laptop', defaultMessage: 'Laptop' }, tablet: { id: 'label.tablet', defaultMessage: 'Tablet' }, @@ -178,8 +182,6 @@ export const labels = defineMessages({ before: { id: 'label.before', defaultMessage: 'Before' }, after: { id: 'label.after', defaultMessage: 'After' }, total: { id: 'label.total', defaultMessage: 'Total' }, - sum: { id: 'label.sum', defaultMessage: 'Sum' }, - average: { id: 'label.average', defaultMessage: 'Average' }, min: { id: 'label.min', defaultMessage: 'Min' }, max: { id: 'label.max', defaultMessage: 'Max' }, unique: { id: 'label.unique', defaultMessage: 'Unique' }, @@ -220,6 +222,10 @@ export const labels = defineMessages({ id: 'message.viewed-page', defaultMessage: 'Viewed page', }, + collectedData: { + id: 'message.collected-data', + defaultMessage: 'Collected data', + }, triggeredEvent: { id: 'message.triggered-event', defaultMessage: 'Triggered event', @@ -239,9 +245,18 @@ export const labels = defineMessages({ goals: { id: 'label.goals', defaultMessage: 'Goals' }, goalsDescription: { id: 'label.goals-description', - defaultMessage: 'Track your goals for pageviews or events.', + defaultMessage: 'Track your goals for pageviews and events.', }, - count: { id: 'label.count', defaultMessage: 'Count' }, + journey: { id: 'label.journey', defaultMessage: 'Journey' }, + journeyDescription: { + id: 'label.journey-description', + defaultMessage: 'Understand how users nagivate through your website.', + }, + compare: { id: 'label.compare', defaultMessage: 'Compare' }, + current: { id: 'label.current', defaultMessage: 'Current' }, + previous: { id: 'label.previous', defaultMessage: 'Previous' }, + previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' }, + previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' }, }); export const messages = defineMessages({ diff --git a/src/components/metrics/ChangeLabel.module.css b/src/components/metrics/ChangeLabel.module.css new file mode 100644 index 00000000..110f9a0d --- /dev/null +++ b/src/components/metrics/ChangeLabel.module.css @@ -0,0 +1,31 @@ +.label { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + font-weight: 700; + padding: 0.1em 0.5em; + border-radius: 5px; + color: var(--base500); + align-self: flex-start; +} + +.positive { + color: var(--green700); + background: var(--green100); +} + +.negative { + color: var(--red700); + background: var(--red100); +} + +.neutral { + color: var(--base700); + background: var(--base100); +} + +.new { + color: var(--blue900); + background: var(--blue100); +} diff --git a/src/components/metrics/ChangeLabel.tsx b/src/components/metrics/ChangeLabel.tsx new file mode 100644 index 00000000..6eefc55b --- /dev/null +++ b/src/components/metrics/ChangeLabel.tsx @@ -0,0 +1,44 @@ +import classNames from 'classnames'; +import { Icon, Icons } from 'react-basics'; +import { ReactNode } from 'react'; +import styles from './ChangeLabel.module.css'; + +export function ChangeLabel({ + value, + size, + reverseColors, + className, + children, +}: { + value: number; + size?: 'xs' | 'sm' | 'md' | 'lg'; + reverseColors?: boolean; + showPercentage?: boolean; + className?: string; + children?: ReactNode; +}) { + const positive = value * (reverseColors ? -1 : 1) >= 0; + const negative = value * (reverseColors ? -1 : 1) < 0; + const isNew = isNaN(value); + + return ( +
+ {!isNew && ( + + + + )} + {children || value} +
+ ); +} + +export default ChangeLabel; diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx index eb2fb703..842ae605 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -13,7 +13,9 @@ export interface EventsChartProps { } export function EventsChart({ websiteId, className }: EventsChartProps) { - const [{ startDate, endDate, unit }] = useDateRange(websiteId); + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange(websiteId); const { locale } = useLocale(); const { data, isLoading } = useWebsiteEvents(websiteId); diff --git a/src/components/metrics/FilterTags.module.css b/src/components/metrics/FilterTags.module.css index fe5c345c..ea7714f4 100644 --- a/src/components/metrics/FilterTags.module.css +++ b/src/components/metrics/FilterTags.module.css @@ -2,6 +2,12 @@ display: flex; align-items: center; gap: 10px; + background: var(--base75); + padding: 10px 20px; + border: 1px solid var(--base400); + border-radius: 8px; + margin-bottom: 20px; + flex-wrap: wrap; } .label { @@ -12,12 +18,13 @@ display: flex; flex-direction: row; align-items: center; - gap: 10px; - background: var(--base75); + gap: 4px; + font-size: 12px; + background: var(--base50); border: 1px solid var(--base400); border-radius: var(--border-radius); box-shadow: 1px 1px 1px var(--base500); - padding: 8px 16px; + padding: 6px 14px; cursor: pointer; } @@ -27,6 +34,8 @@ .close { font-weight: 700; + align-self: center; + margin-left: auto; } .name, diff --git a/src/components/metrics/FilterTags.tsx b/src/components/metrics/FilterTags.tsx index 35d12556..60cf90c1 100644 --- a/src/components/metrics/FilterTags.tsx +++ b/src/components/metrics/FilterTags.tsx @@ -13,6 +13,7 @@ import FieldFilterEditForm from 'app/(main)/reports/[reportId]/FieldFilterEditFo import { OPERATOR_PREFIXES } from 'lib/constants'; import { isSearchOperator, parseParameterValue } from 'lib/params'; import styles from './FilterTags.module.css'; +import WebsiteFilterButton from 'app/(main)/websites/[websiteId]/WebsiteFilterButton'; export function FilterTags({ websiteId, @@ -23,7 +24,7 @@ export function FilterTags({ }) { const { formatMessage, labels } = useMessages(); const { formatValue } = useFormat(); - const [dateRange] = useDateRange(websiteId); + const { dateRange } = useDateRange(websiteId); const { router, renderUrl, @@ -100,6 +101,7 @@ export function FilterTags({ ); })} +
); } -const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true }) => { +const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentage = true }) => { const props = useSpring({ width: percent, y: value, @@ -90,6 +93,7 @@ const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true
{label}
+ {change} {props.y?.to(formatLongNumber)} @@ -97,9 +101,7 @@ const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true {showPercentage && (
`${n}%`) }} /> - - {props.width.to(n => `${n?.toFixed?.(0)}%`)} - + {props.width.to(n => `${n?.toFixed?.(0)}%`)}
)}
diff --git a/src/components/metrics/MetricCard.module.css b/src/components/metrics/MetricCard.module.css index 8ddecc10..93e6c6d7 100644 --- a/src/components/metrics/MetricCard.module.css +++ b/src/components/metrics/MetricCard.module.css @@ -2,47 +2,36 @@ display: flex; flex-direction: column; justify-content: center; - min-height: 90px; - min-width: 140px; + min-width: 150px; +} + +.card.compare .change { + font-size: 16px; + margin: 10px 0; +} + +.card:first-child { + padding-left: 0; +} + +.card:last-child { + border: 0; } .value { - display: flex; - align-items: center; font-size: 36px; font-weight: 700; white-space: nowrap; - min-height: 60px; color: var(--base900); + line-height: 1.5; } -.label { - display: flex; - align-items: center; - font-weight: 700; - gap: 10px; - white-space: nowrap; - min-height: 30px; +.value.prev { color: var(--base800); } -.change { - font-size: 12px; - padding: 0 5px; - border-radius: 5px; - color: var(--base500); -} - -.change.positive { - color: var(--green700); - background: var(--green100); -} - -.change.negative { - color: var(--red700); - background: var(--red100); -} - -.change.plusSign::before { - content: '+'; +.label { + font-weight: 700; + white-space: nowrap; + color: var(--base800); } diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx index 2359b0e2..3c4899c4 100644 --- a/src/components/metrics/MetricCard.tsx +++ b/src/components/metrics/MetricCard.tsx @@ -1,15 +1,19 @@ import classNames from 'classnames'; import { useSpring, animated } from '@react-spring/web'; import { formatNumber } from 'lib/format'; +import ChangeLabel from 'components/metrics/ChangeLabel'; import styles from './MetricCard.module.css'; export interface MetricCardProps { value: number; + previousValue?: number; change?: number; - label: string; + label?: string; reverseColors?: boolean; - format?: typeof formatNumber; - hideComparison?: boolean; + formatValue?: typeof formatNumber; + showLabel?: boolean; + showChange?: boolean; + showPrevious?: boolean; className?: string; } @@ -18,33 +22,37 @@ export const MetricCard = ({ change = 0, label, reverseColors = false, - format = formatNumber, - hideComparison = false, + formatValue = formatNumber, + showLabel = true, + showChange = false, + showPrevious = false, className, }: MetricCardProps) => { + const diff = value - change; + const pct = ((value - diff) / diff) * 100; const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); - const changeProps = useSpring({ x: Number(change) || 0, from: { x: 0 } }); + const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); + const prevProps = useSpring({ x: Number(diff) || 0, from: { x: 0 } }); return ( -
- - {props?.x?.to(x => format(x))} +
+ {showLabel &&
{label}
} + + {props?.x?.to(x => formatValue(x))} -
- {label} - {~~change !== 0 && !hideComparison && ( - = 0, - [styles.negative]: change * (reverseColors ? -1 : 1) < 0, - [styles.plusSign]: change > 0, - })} - title={changeProps?.x as any} - > - {changeProps?.x?.to(x => format(x))} + {showChange && ( + + + {changeProps?.x?.to(x => Math.abs(~~x))} - )} -
+ % + + )} + {showPrevious && ( + + {prevProps?.x?.to(x => formatValue(x))} + + )}
); }; diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx index 857c136b..4ca3ff52 100644 --- a/src/components/metrics/MetricsTable.tsx +++ b/src/components/metrics/MetricsTable.tsx @@ -18,7 +18,6 @@ import styles from './MetricsTable.module.css'; export interface MetricsTableProps extends ListTableProps { websiteId: string; - domainName: string; type?: string; className?: string; dataFilter?: (data: any) => any; @@ -27,6 +26,8 @@ export interface MetricsTableProps extends ListTableProps { onDataLoad?: (data: any) => void; onSearch?: (search: string) => void; allowSearch?: boolean; + showMore?: boolean; + params?: { [key: string]: any }; children?: ReactNode; } @@ -39,6 +40,8 @@ export function MetricsTable({ onDataLoad, delay = null, allowSearch = false, + showMore = true, + params, children, ...props }: MetricsTableProps) { @@ -50,7 +53,7 @@ export function MetricsTable({ const { data, isLoading, isFetched, error } = useWebsiteMetrics( websiteId, - { type, limit, search }, + { type, limit, search, ...params }, { retryDelay: delay || DEFAULT_ANIMATION_DURATION, onDataLoad, @@ -98,7 +101,7 @@ export function MetricsTable({ )} {!data && isLoading && !isFetched && }
- {data && !error && limit && ( + {showMore && data && !error && limit && ( {formatMessage(labels.more)} diff --git a/src/components/metrics/PagesTable.tsx b/src/components/metrics/PagesTable.tsx index b0da8080..d29952d4 100644 --- a/src/components/metrics/PagesTable.tsx +++ b/src/components/metrics/PagesTable.tsx @@ -4,18 +4,21 @@ import MetricsTable, { MetricsTableProps } from './MetricsTable'; import { useMessages } from 'components/hooks'; import { useNavigation } from 'components/hooks'; import { emptyFilter } from 'lib/filters'; +import { useContext } from 'react'; +import { WebsiteContext } from 'app/(main)/websites/[websiteId]/WebsiteProvider'; export interface PagesTableProps extends MetricsTableProps { allowFilter?: boolean; } -export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProps) { +export function PagesTable({ allowFilter, ...props }: PagesTableProps) { const { router, renderUrl, query: { view = 'url' }, } = useNavigation(); const { formatMessage, labels } = useMessages(); + const { domain } = useContext(WebsiteContext); const handleSelect = (key: any) => { router.push(renderUrl({ view: key }), { scroll: true }); @@ -39,9 +42,7 @@ export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProp value={x} label={!x && formatMessage(labels.none)} externalUrl={ - view === 'url' - ? `${domainName.startsWith('http') ? domainName : `https://${domainName}`}${x}` - : null + view === 'url' ? `${domain.startsWith('http') ? domain : `https://${domain}`}${x}` : null } /> ); @@ -50,7 +51,6 @@ export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProp return ( ( - query: string, - params: Record = {}, -): Promise { +async function rawQuery(query: string, params: Record = {}): Promise { if (process.env.LOG_QUERY) { log('QUERY:\n', query); log('PARAMETERS:\n', params); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 697a4836..745673e9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,6 +20,7 @@ export const DEFAULT_DATE_RANGE = '24hour'; export const DEFAULT_WEBSITE_LIMIT = 10; export const DEFAULT_RESET_DATE = '2000-01-01'; export const DEFAULT_PAGE_SIZE = 10; +export const DEFAULT_DATE_COMPARE = 'prev'; export const REALTIME_RANGE = 30; export const REALTIME_INTERVAL = 5000; @@ -115,6 +116,7 @@ export const REPORT_TYPES = { insights: 'insights', retention: 'retention', utm: 'utm', + journey: 'journey', } as const; export const REPORT_PARAMETERS = { diff --git a/src/lib/date.ts b/src/lib/date.ts index 861bbde2..de76f7f3 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -326,3 +326,13 @@ export function getDateLength(startDate: Date, endDate: Date, unit: string | num const { diff } = DATE_FUNCTIONS[unit]; return diff(endDate, startDate) + 1; } + +export function getCompareDate(compare: string, startDate: Date, endDate: Date) { + if (compare === 'yoy') { + return { startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) }; + } + + const diff = differenceInMinutes(endDate, startDate); + + return { startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) }; +} diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts index be2db82f..3a7c4c53 100644 --- a/src/pages/api/reports/[reportId].ts +++ b/src/pages/api/reports/[reportId].ts @@ -27,7 +27,7 @@ const schema: YupRequest = { websiteId: yup.string().uuid().required(), type: yup .string() - .matches(/funnel|insights|retention|utm|goals/i) + .matches(/funnel|insights|retention|utm|goals|journey/i) .required(), name: yup.string().max(200).required(), description: yup.string().max(500), diff --git a/src/pages/api/reports/goals.ts b/src/pages/api/reports/goals.ts index bb766775..f775dc3c 100644 --- a/src/pages/api/reports/goals.ts +++ b/src/pages/api/reports/goals.ts @@ -28,9 +28,23 @@ const schema = { .array() .of( yup.object().shape({ - type: yup.string().required(), + type: yup + .string() + .matches(/url|event|event-data/i) + .required(), value: yup.string().required(), goal: yup.number().required(), + operator: yup + .string() + .matches(/count|sum|average/i) + .when('type', { + is: 'eventData', + then: yup.string().required(), + }), + property: yup.string().when('type', { + is: 'eventData', + then: yup.string().required(), + }), }), ) .min(1) diff --git a/src/pages/api/reports/journey.ts b/src/pages/api/reports/journey.ts new file mode 100644 index 00000000..84246f05 --- /dev/null +++ b/src/pages/api/reports/journey.ts @@ -0,0 +1,54 @@ +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getJourney } from 'queries'; +import * as yup from 'yup'; + +export interface RetentionRequestBody { + websiteId: string; + dateRange: { startDate: string; endDate: string }; +} + +const schema = { + POST: yup.object().shape({ + websiteId: yup.string().uuid().required(), + dateRange: yup + .object() + .shape({ + startDate: yup.date().required(), + endDate: yup.date().required(), + }) + .required(), + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + if (req.method === 'POST') { + const { + websiteId, + dateRange: { startDate, endDate }, + } = req.body; + + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const data = await getJourney(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/pages/api/send.ts b/src/pages/api/send.ts index fe4a2abd..11ba10d7 100644 --- a/src/pages/api/send.ts +++ b/src/pages/api/send.ts @@ -87,7 +87,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => { if (req.method === 'POST') { if (!process.env.DISABLE_BOT_CHECK && isbot(req.headers['user-agent'])) { - return ok(res); + return ok(res, { beep: 'boop' }); } await useValidate(schema, req, res); diff --git a/src/pages/api/websites/[websiteId]/pageviews.ts b/src/pages/api/websites/[websiteId]/pageviews.ts index 19671064..5b631515 100644 --- a/src/pages/api/websites/[websiteId]/pageviews.ts +++ b/src/pages/api/websites/[websiteId]/pageviews.ts @@ -1,3 +1,4 @@ +import * as yup from 'yup'; import { canViewWebsite } from 'lib/auth'; import { useAuth, useCors, useValidate } from 'lib/middleware'; import { getRequestFilters, getRequestDateRange } from 'lib/request'; @@ -5,6 +6,8 @@ import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { getPageviewStats, getSessionStats } from 'queries'; +import { TimezoneTest, UnitTypeTest } from 'lib/yup'; +import { getCompareDate } from 'lib/date'; export interface WebsitePageviewRequestQuery { websiteId: string; @@ -21,10 +24,9 @@ export interface WebsitePageviewRequestQuery { country?: string; region: string; city?: string; + compare?: string; } -import { TimezoneTest, UnitTypeTest } from 'lib/yup'; -import * as yup from 'yup'; const schema = { GET: yup.object().shape({ websiteId: yup.string().uuid().required(), @@ -41,6 +43,7 @@ const schema = { country: yup.string(), region: yup.string(), city: yup.string(), + compare: yup.string(), }), }; @@ -52,7 +55,7 @@ export default async ( await useAuth(req, res); await useValidate(schema, req, res); - const { websiteId, timezone } = req.query; + const { websiteId, timezone, compare } = req.query; if (req.method === 'GET') { if (!(await canViewWebsite(req.auth, websiteId))) { @@ -74,6 +77,40 @@ export default async ( getSessionStats(websiteId, filters), ]); + if (compare) { + const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate( + compare, + startDate, + endDate, + ); + + const [comparePageviews, compareSessions] = await Promise.all([ + getPageviewStats(websiteId, { + ...filters, + startDate: compareStartDate, + endDate: compareEndDate, + }), + getSessionStats(websiteId, { + ...filters, + startDate: compareStartDate, + endDate: compareEndDate, + }), + ]); + + return ok(res, { + pageviews, + sessions, + startDate, + endDate, + compare: { + pageviews: comparePageviews, + sessions: compareSessions, + startDate: compareStartDate, + endDate: compareEndDate, + }, + }); + } + return ok(res, { pageviews, sessions }); } diff --git a/src/pages/api/websites/[websiteId]/stats.ts b/src/pages/api/websites/[websiteId]/stats.ts index 81a6d835..1ce616e7 100644 --- a/src/pages/api/websites/[websiteId]/stats.ts +++ b/src/pages/api/websites/[websiteId]/stats.ts @@ -1,4 +1,4 @@ -import { subMinutes, differenceInMinutes } from 'date-fns'; +import * as yup from 'yup'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { canViewWebsite } from 'lib/auth'; @@ -6,6 +6,7 @@ import { useAuth, useCors, useValidate } from 'lib/middleware'; import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types'; import { getRequestFilters, getRequestDateRange } from 'lib/request'; import { getWebsiteStats } from 'queries'; +import { getCompareDate } from 'lib/date'; export interface WebsiteStatsRequestQuery { websiteId: string; @@ -22,9 +23,9 @@ export interface WebsiteStatsRequestQuery { country?: string; region?: string; city?: string; + compare?: string; } -import * as yup from 'yup'; const schema = { GET: yup.object().shape({ websiteId: yup.string().uuid().required(), @@ -41,6 +42,7 @@ const schema = { country: yup.string(), region: yup.string(), city: yup.string(), + compare: yup.string(), }), }; @@ -52,7 +54,7 @@ export default async ( await useAuth(req, res); await useValidate(schema, req, res); - const { websiteId } = req.query; + const { websiteId, compare } = req.query; if (req.method === 'GET') { if (!(await canViewWebsite(req.auth, websiteId))) { @@ -60,9 +62,11 @@ export default async ( } const { startDate, endDate } = await getRequestDateRange(req); - const diff = differenceInMinutes(endDate, startDate); - const prevStartDate = subMinutes(startDate, diff); - const prevEndDate = subMinutes(endDate, diff); + const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate( + compare, + startDate, + endDate, + ); const filters = getRequestFilters(req); @@ -70,14 +74,14 @@ export default async ( const prevPeriod = await getWebsiteStats(websiteId, { ...filters, - startDate: prevStartDate, - endDate: prevEndDate, + startDate: compareStartDate, + endDate: compareEndDate, }); const stats = Object.keys(metrics[0]).reduce((obj, key) => { obj[key] = { value: Number(metrics[0][key]) || 0, - change: Number(metrics[0][key]) - Number(prevPeriod[0][key]) || 0, + prev: Number(prevPeriod[0][key]) || 0, }; return obj; }, {}); diff --git a/src/queries/analytics/eventData/getEventDataEvents.ts b/src/queries/analytics/eventData/getEventDataEvents.ts index 76ad3fce..e5647deb 100644 --- a/src/queries/analytics/eventData/getEventDataEvents.ts +++ b/src/queries/analytics/eventData/getEventDataEvents.ts @@ -85,8 +85,8 @@ async function clickhouseQuery( limit 500 `, params, - ).then(a => { - return Object.values(a).map(a => { + ).then(result => { + return Object.values(result).map((a: any) => { return { eventName: a.eventName, fieldName: a.fieldName, @@ -113,8 +113,8 @@ async function clickhouseQuery( limit 500 `, params, - ).then(a => { - return Object.values(a).map(a => { + ).then(result => { + return Object.values(result).map((a: any) => { return { eventName: a.eventName, fieldName: a.fieldName, diff --git a/src/queries/analytics/eventData/getEventDataFields.ts b/src/queries/analytics/eventData/getEventDataFields.ts index 6ec3b35a..f669ad39 100644 --- a/src/queries/analytics/eventData/getEventDataFields.ts +++ b/src/queries/analytics/eventData/getEventDataFields.ts @@ -62,8 +62,8 @@ async function clickhouseQuery( limit 500 `, params, - ).then(a => { - return Object.values(a).map(a => { + ).then(result => { + return Object.values(result).map((a: any) => { return { fieldName: a.fieldName, dataType: Number(a.dataType), diff --git a/src/queries/analytics/eventData/getEventDataStats.ts b/src/queries/analytics/eventData/getEventDataStats.ts index 978f561b..09bef107 100644 --- a/src/queries/analytics/eventData/getEventDataStats.ts +++ b/src/queries/analytics/eventData/getEventDataStats.ts @@ -68,8 +68,8 @@ async function clickhouseQuery( ) as t `, params, - ).then(a => { - return Object.values(a).map(a => { + ).then(result => { + return Object.values(result).map((a: any) => { return { events: Number(a.events), fields: Number(a.fields), diff --git a/src/queries/analytics/eventData/getEventDataUsage.ts b/src/queries/analytics/eventData/getEventDataUsage.ts index 7866a600..2a19f33e 100644 --- a/src/queries/analytics/eventData/getEventDataUsage.ts +++ b/src/queries/analytics/eventData/getEventDataUsage.ts @@ -30,8 +30,8 @@ function clickhouseQuery( startDate, endDate, }, - ).then(a => { - return Object.values(a).map(a => { + ).then(result => { + return Object.values(result).map((a: any) => { return { websiteId: a.websiteId, count: Number(a.count) }; }); }); diff --git a/src/queries/analytics/getWebsiteStats.ts b/src/queries/analytics/getWebsiteStats.ts index bc006a2e..6257e166 100644 --- a/src/queries/analytics/getWebsiteStats.ts +++ b/src/queries/analytics/getWebsiteStats.ts @@ -92,13 +92,13 @@ async function clickhouseQuery( `, params, ).then(result => { - return Object.values(result).map(n => { + return Object.values(result).map((a: any) => { return { - pageviews: Number(n.pageviews), - visitors: Number(n.visitors), - visits: Number(n.visits), - bounces: Number(n.bounces), - totaltime: Number(n.totaltime), + pageviews: Number(a.pageviews), + visitors: Number(a.visitors), + visits: Number(a.visits), + bounces: Number(a.bounces), + totaltime: Number(a.totaltime), }; }); }); diff --git a/src/queries/analytics/pageviews/getPageviewMetrics.ts b/src/queries/analytics/pageviews/getPageviewMetrics.ts index eaf4ae32..5f609c12 100644 --- a/src/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/src/queries/analytics/pageviews/getPageviewMetrics.ts @@ -33,8 +33,8 @@ async function relationalQuery( let excludeDomain = ''; if (column === 'referrer_domain') { - excludeDomain = - 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)'; + excludeDomain = `and website_event.referrer_domain != {{websiteDomain}} + and website_event.referrer_domain is not null`; } return rawQuery( @@ -72,7 +72,7 @@ async function clickhouseQuery( let excludeDomain = ''; if (column === 'referrer_domain') { - excludeDomain = 'and referrer_domain != {websiteDomain:String}'; + excludeDomain = `and referrer_domain != {websiteDomain:String} and referrer_domain != ''`; } return rawQuery( @@ -90,8 +90,8 @@ async function clickhouseQuery( offset ${offset} `, params, - ).then(a => { - return Object.values(a).map(a => { + ).then((result: any) => { + return Object.values(result).map((a: any) => { return { x: a.x, y: Number(a.y) }; }); }); diff --git a/src/queries/analytics/pageviews/getPageviewStats.ts b/src/queries/analytics/pageviews/getPageviewStats.ts index 3f29c75f..a37a1566 100644 --- a/src/queries/analytics/pageviews/getPageviewStats.ts +++ b/src/queries/analytics/pageviews/getPageviewStats.ts @@ -67,7 +67,7 @@ async function clickhouseQuery( `, params, ).then(result => { - return Object.values(result).map(a => { + return Object.values(result).map((a: any) => { return { x: a.x, y: Number(a.y) }; }); }); diff --git a/src/queries/analytics/reports/getGoals.ts b/src/queries/analytics/reports/getGoals.ts index d26998d0..83b0ce97 100644 --- a/src/queries/analytics/reports/getGoals.ts +++ b/src/queries/analytics/reports/getGoals.ts @@ -8,7 +8,7 @@ export async function getGoals( criteria: { startDate: Date; endDate: Date; - goals: { type: string; value: string; goal: number }[]; + goals: { type: string; value: string; goal: number; operator?: string }[]; }, ] ) { @@ -23,117 +23,30 @@ async function relationalQuery( criteria: { startDate: Date; endDate: Date; - goals: { type: string; value: string; goal: number }[]; + goals: { type: string; value: string; goal: number; operator?: string }[]; }, ): Promise { const { startDate, endDate, goals } = criteria; const { rawQuery } = prisma; - const hasUrl = goals.some(a => a.type === 'url'); - const hasEvent = goals.some(a => a.type === 'event'); - - function getParameters(goals: { type: string; value: string; goal: number }[]) { - const urls = goals - .filter(a => a.type === 'url') - .reduce((acc, cv, i) => { - acc[`${cv.type}${i}`] = cv.value; - return acc; - }, {}); - - const events = goals - .filter(a => a.type === 'event') - .reduce((acc, cv, i) => { - acc[`${cv.type}${i}`] = cv.value; - return acc; - }, {}); - - return { - urls: { ...urls, startDate, endDate, websiteId }, - events: { ...events, startDate, endDate, websiteId }, - }; - } - - function getColumns(goals: { type: string; value: string; goal: number }[]) { - const urls = goals - .filter(a => a.type === 'url') - .map((a, i) => `COUNT(CASE WHEN url_path = {{url${i}}} THEN 1 END) AS URL${i}`) - .join('\n'); - const events = goals - .filter(a => a.type === 'event') - .map((a, i) => `COUNT(CASE WHEN url_path = {{event${i}}} THEN 1 END) AS EVENT${i}`) - .join('\n'); - - return { urls, events }; - } - - function getWhere(goals: { type: string; value: string; goal: number }[]) { - const urls = goals - .filter(a => a.type === 'url') - .map((a, i) => `{{url${i}}}`) - .join(','); - const events = goals - .filter(a => a.type === 'event') - .map((a, i) => `{{event${i}}}`) - .join(','); - - return { urls: `and url_path in (${urls})`, events: `and event_name in (${events})` }; - } - - const parameters = getParameters(goals); - const columns = getColumns(goals); - const where = getWhere(goals); - - const urls = hasUrl - ? await rawQuery( - ` - select - ${columns.urls} - from website_event - where websiteId = {{websiteId::uuid}} - ${where.urls} - and created_at between {{startDate}} and {{endDate}} - `, - parameters.urls, - ) - : []; - - const events = hasEvent - ? await rawQuery( - ` - select - ${columns.events} - from website_event - where websiteId = {{websiteId::uuid}} - ${where.events} - and created_at between {{startDate}} and {{endDate}} - `, - parameters.events, - ) - : []; - - return [...urls, ...events]; -} - -async function clickhouseQuery( - websiteId: string, - criteria: { - startDate: Date; - endDate: Date; - goals: { type: string; value: string; goal: number }[]; - }, -): Promise<{ type: string; value: string; goal: number; result: number }[]> { - const { startDate, endDate, goals } = criteria; - const { rawQuery } = clickhouse; - const urls = goals.filter(a => a.type === 'url'); const events = goals.filter(a => a.type === 'event'); + const eventData = goals.filter(a => a.type === 'event-data'); const hasUrl = urls.length > 0; const hasEvent = events.length > 0; + const hasEventData = eventData.length > 0; function getParameters( urls: { type: string; value: string; goal: number }[], events: { type: string; value: string; goal: number }[], + eventData: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }[], ) { const urlParam = urls.reduce((acc, cv, i) => { acc[`${cv.type}${i}`] = cv.value; @@ -145,41 +58,258 @@ async function clickhouseQuery( return acc; }, {}); + const eventDataParam = eventData.reduce((acc, cv, i) => { + acc[`eventData${i}`] = cv.value; + acc[`property${i}`] = cv.property; + return acc; + }, {}); + return { urls: { ...urlParam, startDate, endDate, websiteId }, events: { ...eventParam, startDate, endDate, websiteId }, + eventData: { ...eventDataParam, startDate, endDate, websiteId }, }; } function getColumns( urls: { type: string; value: string; goal: number }[], events: { type: string; value: string; goal: number }[], + eventData: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }[], + ) { + const urlColumns = urls + .map((a, i) => `COUNT(CASE WHEN url_path = {{url${i}}} THEN 1 END) AS URL${i},`) + .join('\n') + .slice(0, -1); + const eventColumns = events + .map((a, i) => `COUNT(CASE WHEN event_name = {{event${i}}} THEN 1 END) AS EVENT${i},`) + .join('\n') + .slice(0, -1); + const eventDataColumns = eventData + .map( + (a, i) => + `${ + a.operator === 'average' ? 'avg' : a.operator + }(CASE WHEN event_name = {{eventData${i}}} AND data_key = {{property${i}}} THEN ${ + a.operator === 'count' ? '1' : 'number_value' + } END) AS EVENT_DATA${i},`, + ) + .join('\n') + .slice(0, -1); + + return { urls: urlColumns, events: eventColumns, eventData: eventDataColumns }; + } + + function getWhere( + urls: { type: string; value: string; goal: number }[], + events: { type: string; value: string; goal: number }[], + eventData: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }[], + ) { + const urlWhere = urls.map((a, i) => `{{url${i}}}`).join(','); + const eventWhere = events.map((a, i) => `{{event${i}}}`).join(','); + const eventDataNameWhere = eventData.map((a, i) => `{{eventData${i}}}`).join(','); + const eventDataKeyWhere = eventData.map((a, i) => `{{property${i}}}`).join(','); + + return { + urls: `and url_path in (${urlWhere})`, + events: `and event_name in (${eventWhere})`, + eventData: `and event_name in (${eventDataNameWhere}) and data_key in (${eventDataKeyWhere})`, + }; + } + + const parameters = getParameters(urls, events, eventData); + const columns = getColumns(urls, events, eventData); + const where = getWhere(urls, events, eventData); + + const urlResults = hasUrl + ? await rawQuery( + ` + select + ${columns.urls} + from website_event + where website_id = {{websiteId::uuid}} + ${where.urls} + and created_at between {{startDate}} and {{endDate}} + `, + parameters.urls, + ).then(a => { + const results = a[0]; + + return Object.keys(results).map((key, i) => ({ + ...urls[i], + goal: Number(urls[i].goal), + result: Number(results[key]), + })); + }) + : []; + + const eventResults = hasEvent + ? await rawQuery( + ` + select + ${columns.events} + from website_event + where website_id = {{websiteId::uuid}} + ${where.events} + and created_at between {{startDate}} and {{endDate}} + `, + parameters.events, + ).then(a => { + const results = a[0]; + + return Object.keys(results).map((key, i) => { + return { ...events[i], goal: Number(events[i].goal), result: Number(results[key]) }; + }); + }) + : []; + + const eventDataResults = hasEventData + ? await rawQuery( + ` + select + ${columns.eventData} + from website_event w + join event_data d + on d.website_event_id = w.event_id + where w.website_id = {{websiteId::uuid}} + ${where.eventData} + and w.created_at between {{startDate}} and {{endDate}} + `, + parameters.eventData, + ).then(a => { + const results = a[0]; + + return Object.keys(results).map((key, i) => { + return { ...eventData[i], goal: Number(eventData[i].goal), result: Number(results[key]) }; + }); + }) + : []; + + return [...urlResults, ...eventResults, ...eventDataResults]; +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + startDate: Date; + endDate: Date; + goals: { type: string; value: string; goal: number; operator?: string; property?: string }[]; + }, +): Promise<{ type: string; value: string; goal: number; result: number }[]> { + const { startDate, endDate, goals } = criteria; + const { rawQuery } = clickhouse; + + const urls = goals.filter(a => a.type === 'url'); + const events = goals.filter(a => a.type === 'event'); + const eventData = goals.filter(a => a.type === 'event-data'); + + const hasUrl = urls.length > 0; + const hasEvent = events.length > 0; + const hasEventData = eventData.length > 0; + + function getParameters( + urls: { type: string; value: string; goal: number }[], + events: { type: string; value: string; goal: number }[], + eventData: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }[], + ) { + const urlParam = urls.reduce((acc, cv, i) => { + acc[`${cv.type}${i}`] = cv.value; + return acc; + }, {}); + + const eventParam = events.reduce((acc, cv, i) => { + acc[`${cv.type}${i}`] = cv.value; + return acc; + }, {}); + + const eventDataParam = eventData.reduce((acc, cv, i) => { + acc[`eventData${i}`] = cv.value; + acc[`property${i}`] = cv.property; + return acc; + }, {}); + + return { + urls: { ...urlParam, startDate, endDate, websiteId }, + events: { ...eventParam, startDate, endDate, websiteId }, + eventData: { ...eventDataParam, startDate, endDate, websiteId }, + }; + } + + function getColumns( + urls: { type: string; value: string; goal: number }[], + events: { type: string; value: string; goal: number }[], + eventData: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }[], ) { const urlColumns = urls .map((a, i) => `countIf(url_path = {url${i}:String}) AS URL${i},`) .join('\n') .slice(0, -1); const eventColumns = events - .map((a, i) => `countIf(event_name = {event${i}:String}) AS EVENT${i}`) + .map((a, i) => `countIf(event_name = {event${i}:String}) AS EVENT${i},`) + .join('\n') + .slice(0, -1); + const eventDataColumns = eventData + .map( + (a, i) => + `${a.operator === 'average' ? 'avg' : a.operator}If(${ + a.operator !== 'count' ? 'number_value, ' : '' + }event_name = {eventData${i}:String} AND data_key = {property${i}:String}) AS EVENT_DATA${i},`, + ) .join('\n') .slice(0, -1); - return { url: urlColumns, events: eventColumns }; + return { url: urlColumns, events: eventColumns, eventData: eventDataColumns }; } function getWhere( urls: { type: string; value: string; goal: number }[], events: { type: string; value: string; goal: number }[], + eventData: { + type: string; + value: string; + goal: number; + operator?: string; + property?: string; + }[], ) { const urlWhere = urls.map((a, i) => `{url${i}:String}`).join(','); const eventWhere = events.map((a, i) => `{event${i}:String}`).join(','); + const eventDataNameWhere = eventData.map((a, i) => `{eventData${i}:String}`).join(','); + const eventDataKeyWhere = eventData.map((a, i) => `{property${i}:String}`).join(','); - return { urls: `and url_path in (${urlWhere})`, events: `and event_name in (${eventWhere})` }; + return { + urls: `and url_path in (${urlWhere})`, + events: `and event_name in (${eventWhere})`, + eventData: `and event_name in (${eventDataNameWhere}) and data_key in (${eventDataKeyWhere})`, + }; } - const parameters = getParameters(urls, events); - const columns = getColumns(urls, events); - const where = getWhere(urls, events); + const parameters = getParameters(urls, events, eventData); + const columns = getColumns(urls, events, eventData); + const where = getWhere(urls, events, eventData); const urlResults = hasUrl ? await rawQuery( @@ -221,5 +351,25 @@ async function clickhouseQuery( }) : []; - return [...urlResults, ...eventResults]; + const eventDataResults = hasEventData + ? await rawQuery( + ` + select + ${columns.eventData} + from event_data + where website_id = {websiteId:UUID} + ${where.eventData} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + `, + parameters.eventData, + ).then(a => { + const results = a[0]; + + return Object.keys(results).map((key, i) => { + return { ...eventData[i], goal: Number(eventData[i].goal), result: Number(results[key]) }; + }); + }) + : []; + + return [...urlResults, ...eventResults, ...eventDataResults]; } diff --git a/src/queries/analytics/reports/getJourney.ts b/src/queries/analytics/reports/getJourney.ts new file mode 100644 index 00000000..088f7ee8 --- /dev/null +++ b/src/queries/analytics/reports/getJourney.ts @@ -0,0 +1,148 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; + +export async function getJourney( + ...args: [ + websiteId: string, + filters: { + startDate: Date; + endDate: Date; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: { + startDate: Date; + endDate: Date; + }, +): Promise< + { + e1: string; + e2: string; + e3: string; + e4: string; + e5: string; + count: string; + }[] +> { + const { startDate, endDate } = filters; + const { rawQuery } = prisma; + + return rawQuery( + ` + WITH events AS ( + select distinct + session_id, + referrer_path, + COALESCE(event_name, url_path) event, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and referrer_path != url_path), + sequences as ( + SELECT s.e1, + s.e2, + s.e3, + s.e4, + s.e5, + count(*) count + FROM ( + SELECT session_id, + MAX(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1, + MAX(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2, + MAX(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3, + MAX(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4, + MAX(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5 + FROM events + group by session_id) s + group by s.e1, + s.e2, + s.e3, + s.e4, + s.e5) + select * + from sequences + order by count desc + limit 100 + `, + { + websiteId, + startDate, + endDate, + }, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: { + startDate: Date; + endDate: Date; + }, +): Promise< + { + e1: string; + e2: string; + e3: string; + e4: string; + e5: string; + count: string; + }[] +> { + const { startDate, endDate } = filters; + const { rawQuery } = clickhouse; + + return rawQuery( + ` + WITH events AS ( + select distinct + session_id, + referrer_path, + coalesce(nullIf(event_name, ''), url_path) event, + row_number() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number + from umami.website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and referrer_path != url_path), + sequences as ( + SELECT s.e1, + s.e2, + s.e3, + s.e4, + s.e5, + count(*) count + FROM ( + SELECT session_id, + max(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1, + max(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2, + max(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3, + max(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4, + max(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5 + FROM events + group by session_id) s + group by s.e1, + s.e2, + s.e3, + s.e4, + s.e5) + select * + from sequences + order by count desc + limit 100 + `, + { + websiteId, + startDate, + endDate, + }, + ); +} diff --git a/src/queries/index.ts b/src/queries/index.ts index f0002881..8cef080a 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataStats'; export * from './analytics/eventData/getEventDataUsage'; export * from './analytics/events/saveEvent'; export * from './analytics/reports/getFunnel'; +export * from './analytics/reports/getJourney'; export * from './analytics/reports/getRetention'; export * from './analytics/reports/getInsights'; export * from './analytics/reports/getUTM'; diff --git a/src/store/websites.ts b/src/store/websites.ts index a9f6b44d..1c5c21fc 100644 --- a/src/store/websites.ts +++ b/src/store/websites.ts @@ -18,4 +18,18 @@ export function setWebsiteDateRange(websiteId: string, dateRange: DateRange) { ); } +export function setWebsiteDateCompare(websiteId: string, dateCompare: string) { + store.setState( + produce(state => { + if (!state[websiteId]) { + state[websiteId] = {}; + } + + state[websiteId].dateCompare = dateCompare; + + return state; + }), + ); +} + export default store; diff --git a/yarn.lock b/yarn.lock index 80b8e454..4ba6684d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1206,17 +1206,17 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@clickhouse/client-common@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@clickhouse/client-common/-/client-common-1.0.1.tgz#c7dde5eafaad8189649373ecc23354c7a32847b3" - integrity sha512-3L6e0foP6VOktScoi6XWMjJyOpKCWgLUYgPVxP2c7gm6Kotq+iRmmmXtXTSg7B7uozcLZycTtPfIw2d80SYsYw== +"@clickhouse/client-common@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@clickhouse/client-common/-/client-common-1.0.2.tgz#0fe0a4b33101c08d85c1279e4d74b2a92d42d996" + integrity sha512-5oI2URFsXlzoysv5lAxoTUAnAHjXnaJ+1Jz3HUARR06Hkbr1sN0pGxfGwgjEd8E/lI4+UNdNEZicG2rlFnWSaA== -"@clickhouse/client@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@clickhouse/client/-/client-1.0.1.tgz#364db28d9ef9beaf19104f962c2b06090cb10468" - integrity sha512-fluUNnE2R7COJ6rn6DorYfi4D+AQn3t2qeBtEq37bQV3pD4EbKrBfKAwJ13e1lmMWdQ2B9bJUTMqGsRIDdWhJw== +"@clickhouse/client@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@clickhouse/client/-/client-1.0.2.tgz#7d9675e697ce697f1e6777f4c66ca6d3384e7325" + integrity sha512-PaK0GLjIrlCpXevrp9gliOrurna6MjMMFBgZhDh6Zup8IuJCjQru4LkNsWUl3hJ2nua6+Ygag14iB8ILbeaIjg== dependencies: - "@clickhouse/client-common" "1.0.1" + "@clickhouse/client-common" "1.0.2" "@colors/colors@1.5.0": version "1.5.0" @@ -2089,51 +2089,51 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@prisma/client@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.13.0.tgz#b9f1d0983d714e982675201d8222a9ecb4bdad4a" - integrity sha512-uYdfpPncbZ/syJyiYBwGZS8Gt1PTNoErNYMuqHDa2r30rNSFtgTA/LXsSk55R7pdRTMi5pHkeP9B14K6nHmwkg== +"@prisma/client@5.14.0": + version "5.14.0" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.14.0.tgz#dadca5bb1137ddcebb454bbdaf89423823d3363f" + integrity sha512-akMSuyvLKeoU4LeyBAUdThP/uhVP3GuLygFE3MlYzaCb3/J8SfsYBE5PkaFuLuVpLyA6sFoW+16z/aPhNAESqg== -"@prisma/debug@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-5.13.0.tgz#d88b0f6fafa0c216e20e284ed9fc30f1cbe45786" - integrity sha512-699iqlEvzyCj9ETrXhs8o8wQc/eVW+FigSsHpiskSFydhjVuwTJEfj/nIYqTaWFYuxiWQRfm3r01meuW97SZaQ== +"@prisma/debug@5.14.0": + version "5.14.0" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-5.14.0.tgz#1227c705893c38284f7c63d72441480ebaa12605" + integrity sha512-iq56qBZuFfX3fCxoxT8gBX33lQzomBU0qIUaEj1RebsKVz1ob/BVH1XSBwwwvRVtZEV1b7Fxx2eVu34Ge/mg3w== -"@prisma/engines-version@5.13.0-23.b9a39a7ee606c28e3455d0fd60e78c3ba82b1a2b": - version "5.13.0-23.b9a39a7ee606c28e3455d0fd60e78c3ba82b1a2b" - resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.13.0-23.b9a39a7ee606c28e3455d0fd60e78c3ba82b1a2b.tgz#a72a4fb83ba1fd01ad45f795aa55168f60d34723" - integrity sha512-AyUuhahTINGn8auyqYdmxsN+qn0mw3eg+uhkp8zwknXYIqoT3bChG4RqNY/nfDkPvzWAPBa9mrDyBeOnWSgO6A== +"@prisma/engines-version@5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48": + version "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48.tgz#019c3c75a5c3276e580685fe48cdbfd181176858" + integrity sha512-ip6pNkRo1UxWv+6toxNcYvItNYaqQjXdFNGJ+Nuk2eYtRoEdoF13wxo7/jsClJFFenMPVNVqXQDV0oveXnR1cA== -"@prisma/engines@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.13.0.tgz#8994ebf7b4e35aee7746a8465ec22738379bcab6" - integrity sha512-hIFLm4H1boj6CBZx55P4xKby9jgDTeDG0Jj3iXtwaaHmlD5JmiDkZhh8+DYWkTGchu+rRF36AVROLnk0oaqhHw== +"@prisma/engines@5.14.0": + version "5.14.0" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.14.0.tgz#2ee91dd2220a726c27c906fbea788bbb3efdac6e" + integrity sha512-lgxkKZ6IEygVcw6IZZUlPIfLQ9hjSYAtHjZ5r64sCLDgVzsPFCi2XBBJgzPMkOQ5RHzUD4E/dVdpn9+ez8tk1A== dependencies: - "@prisma/debug" "5.13.0" - "@prisma/engines-version" "5.13.0-23.b9a39a7ee606c28e3455d0fd60e78c3ba82b1a2b" - "@prisma/fetch-engine" "5.13.0" - "@prisma/get-platform" "5.13.0" + "@prisma/debug" "5.14.0" + "@prisma/engines-version" "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48" + "@prisma/fetch-engine" "5.14.0" + "@prisma/get-platform" "5.14.0" "@prisma/extension-read-replicas@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@prisma/extension-read-replicas/-/extension-read-replicas-0.3.0.tgz#2842a7c928f957c1dd58a6256104797596d43426" integrity sha512-F9+rSmYday6GT2qjhJtkZcBOpLO5LtpvFcMGqrBDHf+78LEdSuxfFjOxYlNuqk4B+th62yxpbhfpmB9/Mca14Q== -"@prisma/fetch-engine@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-5.13.0.tgz#9b6945c7b38bb59e840f8905b20ea7a3d059ca55" - integrity sha512-Yh4W+t6YKyqgcSEB3odBXt7QyVSm0OQlBSldQF2SNXtmOgMX8D7PF/fvH6E6qBCpjB/yeJLy/FfwfFijoHI6sA== +"@prisma/fetch-engine@5.14.0": + version "5.14.0" + resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-5.14.0.tgz#45297c118d4ec3fea55129886edd5a429da1f6da" + integrity sha512-VrheA9y9DMURK5vu8OJoOgQpxOhas3qF0IBHJ8G/0X44k82kc8E0w98HCn2nhnbOOMwbWsJWXfLC2/F8n5u0gQ== dependencies: - "@prisma/debug" "5.13.0" - "@prisma/engines-version" "5.13.0-23.b9a39a7ee606c28e3455d0fd60e78c3ba82b1a2b" - "@prisma/get-platform" "5.13.0" + "@prisma/debug" "5.14.0" + "@prisma/engines-version" "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48" + "@prisma/get-platform" "5.14.0" -"@prisma/get-platform@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-5.13.0.tgz#99ef909a52b9d79b64d72d2d3d8210c4892b6572" - integrity sha512-B/WrQwYTzwr7qCLifQzYOmQhZcFmIFhR81xC45gweInSUn2hTEbfKUPd2keAog+y5WI5xLAFNJ3wkXplvSVkSw== +"@prisma/get-platform@5.14.0": + version "5.14.0" + resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-5.14.0.tgz#69112d3dde61905f59a65ed818f153e153ca40f0" + integrity sha512-/yAyBvcEjRv41ynZrhdrPtHgk47xLRRq/o5eWGcUpBJ1YrUZTYB8EoPiopnP7iQrMATK8stXQdPOoVlrzuTQZw== dependencies: - "@prisma/debug" "5.13.0" + "@prisma/debug" "5.14.0" "@react-spring/animated@~9.7.3": version "9.7.3" @@ -8659,12 +8659,12 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -prisma@5.13.0: - version "5.13.0" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.13.0.tgz#1f06e20ccfb6038ad68869e6eacd3b346f9d0851" - integrity sha512-kGtcJaElNRAdAGsCNykFSZ7dBKpL14Cbs+VaQ8cECxQlRPDjBlMHNFYeYt0SKovAVy2Y65JXQwB3A5+zIQwnTg== +prisma@5.14.0: + version "5.14.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.14.0.tgz#ffc4696a43b044b636c3303b7aa98c13c2ade4dd" + integrity sha512-gCNZco7y5XtjrnQYeDJTiVZmT/ncqCr5RY1/Cf8X2wgLRmyh9ayPAGBNziI4qEE4S6SxCH5omQLVo9lmURaJ/Q== dependencies: - "@prisma/engines" "5.13.0" + "@prisma/engines" "5.14.0" process@^0.11.10: version "0.11.10" @@ -9590,16 +9590,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9672,14 +9663,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10440,7 +10424,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10458,15 +10442,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"