Compare commits

..

19 commits

Author SHA1 Message Date
Mike Cao
41d2a24f9d Merge branch 'master' into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
# Conflicts:
#	src/components/common/PageHeader.tsx
#	src/components/metrics/ActiveUsers.tsx
2025-12-03 00:17:44 -08:00
Mike Cao
1ae13513d2 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-12-03 00:16:22 -08:00
Mike Cao
9a269ab811
Merge pull request #3805 from prince0xdev/feat/mobile-navigation-improvement
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Improve mobile navigation: clickable “online” badge & page title
2025-12-03 00:16:16 -08:00
Mike Cao
32aee652a5
Merge pull request #3824 from IndraGunawan/disable-prefetch-view-link
fix: disable prefetch for Links view button
2025-12-03 00:14:11 -08:00
Mike Cao
16cae691f6 Don't prefetch links/pixels. Closes #3814 2025-12-03 00:03:56 -08:00
Mike Cao
1390e09400 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-12-03 00:01:36 -08:00
Mike Cao
23ff20a10b
Merge pull request #3809 from RaenonX/master
Allow `browser` / `os` / `device` override in `payload` & return `cache` from `/api/batch`
2025-12-03 00:01:30 -08:00
Mike Cao
58acee8d25 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-12-02 23:32:53 -08:00
Mike Cao
a0940d78a7 Updated packages. 2025-12-02 23:32:44 -08:00
Indra Gunawan
89b985652a fix: disable prefetch for Links view button 2025-12-03 15:31:54 +08:00
Mike Cao
7b3be59c8d
Merge pull request #3819 from Lokimorty/seed-sample-data
feat(dev): add sample data generator script
2025-12-02 23:30:55 -08:00
Mike Cao
b08413ebea
Merge branch 'dev' into seed-sample-data 2025-12-02 23:30:47 -08:00
Arthur Sepiol
c481bc5dcc chore: exclude seed scripts from Docker builds 2025-12-02 20:25:25 +03:00
Arthur Sepiol
b7807ed466 feat(dev): add sample data generator script
Adds a CLI tool to generate realistic analytics data for local development and testing.
Creates two demo websites with varying traffic patterns and realistic user behavior distributions.
2025-12-02 13:43:59 +03:00
RaenonX
c86ea1a74f
Updated /api/batch to return cache 2025-12-01 04:06:17 +08:00
RaenonX
805bc57bbb
Added browser / os / device override in payload 2025-12-01 04:06:17 +08:00
RaenonX
92a7355ce3
Fixed /api/batch request recreation failure 2025-12-01 04:06:10 +08:00
Prince EKPINSE
beb2bc0a06 feat: improve mobile navigation with clickable page elements (#3770) 2025-11-29 13:53:32 +01:00
Prince EKPINSE
776e404c6f fix: [#3778] update 'Edit' word to support translation 2025-11-29 12:40:22 +01:00
77 changed files with 1888 additions and 88 deletions

View file

@ -7,3 +7,5 @@ node_modules
.idea
.env
.env.*
scripts/seed
scripts/seed-data.ts

View file

@ -47,6 +47,7 @@
"test": "jest",
"cypress-open": "cypress open cypress run",
"cypress-run": "cypress run cypress run",
"seed-data": "tsx scripts/seed-data.ts",
"lint": "biome lint .",
"format": "biome format --write .",
"check": "biome check --write"
@ -72,7 +73,7 @@
"@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.5",
"@umami/react-zen": "^0.210.0",
"@umami/react-zen": "^0.211.0",
"@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2",
"chalk": "^5.6.2",
@ -167,6 +168,7 @@
"ts-jest": "^29.4.5",
"ts-node": "^10.9.1",
"tsup": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}

193
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.90.5
version: 5.90.10(react@19.2.0)
'@umami/react-zen':
specifier: ^0.210.0
version: 0.210.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
specifier: ^0.211.0
version: 0.211.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
'@umami/redis-client':
specifier: ^0.29.0
version: 0.29.0
@ -323,7 +323,10 @@ importers:
version: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
tsup:
specifier: ^8.5.0
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1)
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
@ -1693,8 +1696,8 @@ packages:
'@next/env@15.5.3':
resolution: {integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==}
'@next/env@15.5.6':
resolution: {integrity: sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==}
'@next/env@16.0.6':
resolution: {integrity: sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg==}
'@next/swc-darwin-arm64@15.5.3':
resolution: {integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==}
@ -1702,8 +1705,8 @@ packages:
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-arm64@15.5.6':
resolution: {integrity: sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==}
'@next/swc-darwin-arm64@16.0.6':
resolution: {integrity: sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -1714,8 +1717,8 @@ packages:
cpu: [x64]
os: [darwin]
'@next/swc-darwin-x64@15.5.6':
resolution: {integrity: sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==}
'@next/swc-darwin-x64@16.0.6':
resolution: {integrity: sha512-LlLLNrK9WCIUkq2GciWDcquXYIf7vLxX8XE49gz7EncssZGL1vlHwgmURiJsUZAvk0HM1a8qb1ABDezsjAE/jw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -1726,8 +1729,8 @@ packages:
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-gnu@15.5.6':
resolution: {integrity: sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==}
'@next/swc-linux-arm64-gnu@16.0.6':
resolution: {integrity: sha512-r04NzmLSGGfG8EPXKVK72N5zDNnq9pa9el78LhdtqIC3zqKh74QfKHnk24DoK4PEs6eY7sIK/CnNpt30oc59kg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1738,8 +1741,8 @@ packages:
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.5.6':
resolution: {integrity: sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==}
'@next/swc-linux-arm64-musl@16.0.6':
resolution: {integrity: sha512-hfB/QV0hA7lbD1OJxp52wVDlpffUMfyxUB5ysZbb/pBC5iuhyLcEKSVQo56PFUUmUQzbMsAtUu6k2Gh9bBtWXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1750,8 +1753,8 @@ packages:
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-gnu@15.5.6':
resolution: {integrity: sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==}
'@next/swc-linux-x64-gnu@16.0.6':
resolution: {integrity: sha512-PZJushBgfvKhJBy01yXMdgL+l5XKr7uSn5jhOQXQXiH3iPT2M9iG64yHpPNGIKitKrHJInwmhPVGogZBAJOCPw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1762,8 +1765,8 @@ packages:
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.5.6':
resolution: {integrity: sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==}
'@next/swc-linux-x64-musl@16.0.6':
resolution: {integrity: sha512-LqY76IojrH9yS5fyATjLzlOIOgwyzBuNRqXwVxcGfZ58DWNQSyfnLGlfF6shAEqjwlDNLh4Z+P0rnOI87Y9jEw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1774,8 +1777,8 @@ packages:
cpu: [arm64]
os: [win32]
'@next/swc-win32-arm64-msvc@15.5.6':
resolution: {integrity: sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==}
'@next/swc-win32-arm64-msvc@16.0.6':
resolution: {integrity: sha512-eIfSNNqAkj0tqKRf0u7BVjqylJCuabSrxnpSENY3YKApqwDMeAqYPmnOwmVe6DDl3Lvkbe7cJAyP6i9hQ5PmmQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -1786,8 +1789,8 @@ packages:
cpu: [x64]
os: [win32]
'@next/swc-win32-x64-msvc@15.5.6':
resolution: {integrity: sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==}
'@next/swc-win32-x64-msvc@16.0.6':
resolution: {integrity: sha512-QGs18P4OKdK9y2F3Th42+KGnwsc2iaThOe6jxQgP62kslUU4W+g6AzI6bdIn/pslhSfxjAMU5SjakfT5Fyo/xA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -2947,8 +2950,8 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@umami/react-zen@0.210.0':
resolution: {integrity: sha512-nQ8EfrSleuXMPBVabr6rDoH2VS0ca41A3V2OCQbG4HqgLJ5+Mj8gHT/aLqUz5EKNBAmMy0/XxPNAgsHwwoxrCQ==}
'@umami/react-zen@0.211.0':
resolution: {integrity: sha512-e9dfsmMYpClYU/xQ+nwFo4ktAJc6eth4k6lpdD4j47FD5PaMfSY1FK1qJ7yq/JVN0Ydomc8cuWBDZbHpG4sQmQ==}
'@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -3277,8 +3280,8 @@ packages:
caniuse-lite@1.0.30001741:
resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==}
caniuse-lite@1.0.30001756:
resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==}
caniuse-lite@1.0.30001759:
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@ -4176,6 +4179,9 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
getos@3.2.1:
resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==}
@ -4194,6 +4200,10 @@ packages:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
glob@13.0.0:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
@ -5037,6 +5047,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -5044,13 +5058,13 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lucide-react@0.511.0:
resolution: {integrity: sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==}
lucide-react@0.543.0:
resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lucide-react@0.543.0:
resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==}
lucide-react@0.555.0:
resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -5161,6 +5175,10 @@ packages:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22}
minimatch@10.1.1:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -5249,9 +5267,9 @@ packages:
sass:
optional: true
next@15.5.6:
resolution: {integrity: sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
next@16.0.6:
resolution: {integrity: sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@ -5451,6 +5469,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.1:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
path-type@3.0.0:
resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==}
engines: {node: '>=4'}
@ -6119,8 +6141,8 @@ packages:
peerDependencies:
react: '>=16.13.1'
react-hook-form@7.66.1:
resolution: {integrity: sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==}
react-hook-form@7.67.0:
resolution: {integrity: sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@ -6271,6 +6293,9 @@ packages:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve.exports@2.0.3:
resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==}
engines: {node: '>=10'}
@ -6883,6 +6908,11 @@ packages:
typescript:
optional: true
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
@ -8450,54 +8480,54 @@ snapshots:
'@next/env@15.5.3': {}
'@next/env@15.5.6': {}
'@next/env@16.0.6': {}
'@next/swc-darwin-arm64@15.5.3':
optional: true
'@next/swc-darwin-arm64@15.5.6':
'@next/swc-darwin-arm64@16.0.6':
optional: true
'@next/swc-darwin-x64@15.5.3':
optional: true
'@next/swc-darwin-x64@15.5.6':
'@next/swc-darwin-x64@16.0.6':
optional: true
'@next/swc-linux-arm64-gnu@15.5.3':
optional: true
'@next/swc-linux-arm64-gnu@15.5.6':
'@next/swc-linux-arm64-gnu@16.0.6':
optional: true
'@next/swc-linux-arm64-musl@15.5.3':
optional: true
'@next/swc-linux-arm64-musl@15.5.6':
'@next/swc-linux-arm64-musl@16.0.6':
optional: true
'@next/swc-linux-x64-gnu@15.5.3':
optional: true
'@next/swc-linux-x64-gnu@15.5.6':
'@next/swc-linux-x64-gnu@16.0.6':
optional: true
'@next/swc-linux-x64-musl@15.5.3':
optional: true
'@next/swc-linux-x64-musl@15.5.6':
'@next/swc-linux-x64-musl@16.0.6':
optional: true
'@next/swc-win32-arm64-msvc@15.5.3':
optional: true
'@next/swc-win32-arm64-msvc@15.5.6':
'@next/swc-win32-arm64-msvc@16.0.6':
optional: true
'@next/swc-win32-x64-msvc@15.5.3':
optional: true
'@next/swc-win32-x64-msvc@15.5.6':
'@next/swc-win32-x64-msvc@16.0.6':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -10087,21 +10117,21 @@ snapshots:
'@types/node': 24.10.1
optional: true
'@umami/react-zen@0.210.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
'@umami/react-zen@0.211.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.8
'@internationalized/date': 3.10.0
'@react-aria/focus': 3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-spring/web': 9.7.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
classnames: 2.5.1
glob: 10.5.0
glob: 13.0.0
highlight.js: 11.11.1
lucide-react: 0.511.0(react@19.2.0)
next: 15.5.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lucide-react: 0.555.0(react@19.2.0)
next: 16.0.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-aria-components: 1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0)
react-hook-form: 7.66.1(react@19.2.0)
react-hook-form: 7.67.0(react@19.2.0)
react-icons: 5.5.0(react@19.2.0)
thenby: 1.3.4
zustand: 5.0.8(@types/react@19.2.6)(immer@10.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
@ -10507,7 +10537,7 @@ snapshots:
caniuse-lite@1.0.30001741: {}
caniuse-lite@1.0.30001756: {}
caniuse-lite@1.0.30001759: {}
caseless@0.12.0: {}
@ -11586,6 +11616,10 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
get-tsconfig@4.13.0:
dependencies:
resolve-pkg-maps: 1.0.0
getos@3.2.1:
dependencies:
async: 3.2.6
@ -11616,6 +11650,12 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.0:
dependencies:
minimatch: 10.1.1
minipass: 7.1.2
path-scurry: 2.0.1
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@ -12646,6 +12686,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -12654,11 +12696,11 @@ snapshots:
dependencies:
yallist: 4.0.0
lucide-react@0.511.0(react@19.2.0):
lucide-react@0.543.0(react@19.2.0):
dependencies:
react: 19.2.0
lucide-react@0.543.0(react@19.2.0):
lucide-react@0.555.0(react@19.2.0):
dependencies:
react: 19.2.0
@ -12769,6 +12811,10 @@ snapshots:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimatch@10.1.1:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@ -12853,24 +12899,24 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.5.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next@16.0.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 15.5.6
'@next/env': 16.0.6
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001756
caniuse-lite: 1.0.30001759
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.3)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.6
'@next/swc-darwin-x64': 15.5.6
'@next/swc-linux-arm64-gnu': 15.5.6
'@next/swc-linux-arm64-musl': 15.5.6
'@next/swc-linux-x64-gnu': 15.5.6
'@next/swc-linux-x64-musl': 15.5.6
'@next/swc-win32-arm64-msvc': 15.5.6
'@next/swc-win32-x64-msvc': 15.5.6
'@next/swc-darwin-arm64': 16.0.6
'@next/swc-darwin-x64': 16.0.6
'@next/swc-linux-arm64-gnu': 16.0.6
'@next/swc-linux-arm64-musl': 16.0.6
'@next/swc-linux-x64-gnu': 16.0.6
'@next/swc-linux-x64-musl': 16.0.6
'@next/swc-win32-arm64-msvc': 16.0.6
'@next/swc-win32-x64-msvc': 16.0.6
babel-plugin-react-compiler: 19.1.0-rc.2
sharp: 0.34.5
transitivePeerDependencies:
@ -13055,6 +13101,11 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.1:
dependencies:
lru-cache: 11.2.4
minipass: 7.1.2
path-type@3.0.0:
dependencies:
pify: 3.0.0
@ -13286,12 +13337,13 @@ snapshots:
postcss: 8.5.6
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.1):
postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 2.6.1
postcss: 8.5.6
tsx: 4.21.0
yaml: 2.8.1
postcss-logical@5.0.4(postcss@8.5.6):
@ -13750,7 +13802,7 @@ snapshots:
'@babel/runtime': 7.28.3
react: 19.2.0
react-hook-form@7.66.1(react@19.2.0):
react-hook-form@7.67.0(react@19.2.0):
dependencies:
react: 19.2.0
@ -13952,6 +14004,8 @@ snapshots:
resolve-from@5.0.0: {}
resolve-pkg-maps@1.0.0: {}
resolve.exports@2.0.3: {}
resolve@1.22.10:
@ -14704,7 +14758,7 @@ snapshots:
tslib@2.8.1: {}
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1):
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.0)
cac: 6.7.14
@ -14715,7 +14769,7 @@ snapshots:
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.1)
postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1)
resolve-from: 5.0.0
rollup: 4.53.3
source-map: 0.7.6
@ -14732,6 +14786,13 @@ snapshots:
- tsx
- yaml
tsx@4.21.0:
dependencies:
esbuild: 0.27.0
get-tsconfig: 4.13.0
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1

121
scripts/seed-data.ts Normal file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Umami Sample Data Generator
*
* Generates realistic analytics data for local development and testing.
* Creates two demo websites:
* - Demo Blog: Low traffic (~100 sessions/month)
* - Demo SaaS: Average traffic (~500 sessions/day)
*
* Usage:
* npm run seed-data # Generate 30 days of data
* npm run seed-data -- --days 90 # Generate 90 days of data
* npm run seed-data -- --clear # Clear existing demo data first
* npm run seed-data -- --verbose # Show detailed progress
*/
import { seed, type SeedConfig } from './seed/index.js';
function parseArgs(): SeedConfig {
const args = process.argv.slice(2);
const config: SeedConfig = {
days: 30,
clear: false,
verbose: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--days' && args[i + 1]) {
config.days = parseInt(args[i + 1], 10);
if (isNaN(config.days) || config.days < 1) {
console.error('Error: --days must be a positive integer');
process.exit(1);
}
i++;
} else if (arg === '--clear') {
config.clear = true;
} else if (arg === '--verbose' || arg === '-v') {
config.verbose = true;
} else if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
} else if (arg.startsWith('--days=')) {
config.days = parseInt(arg.split('=')[1], 10);
if (isNaN(config.days) || config.days < 1) {
console.error('Error: --days must be a positive integer');
process.exit(1);
}
}
}
return config;
}
function printHelp(): void {
console.log(`
Umami Sample Data Generator
Generates realistic analytics data for local development and testing.
Usage:
npm run seed-data [options]
Options:
--days <number> Number of days of data to generate (default: 30)
--clear Clear existing demo data before generating
--verbose, -v Show detailed progress
--help, -h Show this help message
Examples:
npm run seed-data # Generate 30 days of data
npm run seed-data -- --days 90 # Generate 90 days of data
npm run seed-data -- --clear # Clear existing demo data first
npm run seed-data -- --days 7 -v # Generate 7 days with verbose output
Generated Sites:
- Demo Blog: Low traffic (~90 sessions/month)
- Demo SaaS: Average traffic (~500 sessions/day) with revenue tracking
Note:
This script is blocked from running in production environments
(NODE_ENV=production or cloud platforms like Vercel/Netlify/Railway).
`);
}
function checkEnvironment(): void {
const nodeEnv = process.env.NODE_ENV;
if (nodeEnv === 'production') {
console.error('\nError: seed-data cannot run in production environment.');
console.error('This script is only for local development and testing.\n');
process.exit(1);
}
if (process.env.VERCEL || process.env.NETLIFY || process.env.RAILWAY_ENVIRONMENT) {
console.error('\nError: seed-data cannot run in cloud environments.');
console.error('This script is only for local development and testing.\n');
process.exit(1);
}
}
async function main(): Promise<void> {
console.log('\nUmami Sample Data Generator\n');
checkEnvironment();
const config = parseArgs();
try {
await seed(config);
} catch (error) {
console.error('\nError generating seed data:', error);
process.exit(1);
}
}
main();

View file

@ -0,0 +1,80 @@
import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js';
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
const deviceWeights: WeightedOption<DeviceType>[] = [
{ value: 'desktop', weight: 0.55 },
{ value: 'mobile', weight: 0.4 },
{ value: 'tablet', weight: 0.05 },
];
const browsersByDevice: Record<DeviceType, WeightedOption<string>[]> = {
desktop: [
{ value: 'Chrome', weight: 0.65 },
{ value: 'Safari', weight: 0.12 },
{ value: 'Firefox', weight: 0.1 },
{ value: 'Edge', weight: 0.1 },
{ value: 'Opera', weight: 0.03 },
],
mobile: [
{ value: 'Chrome', weight: 0.55 },
{ value: 'Safari', weight: 0.35 },
{ value: 'Samsung', weight: 0.05 },
{ value: 'Firefox', weight: 0.03 },
{ value: 'Opera', weight: 0.02 },
],
tablet: [
{ value: 'Safari', weight: 0.6 },
{ value: 'Chrome', weight: 0.35 },
{ value: 'Firefox', weight: 0.05 },
],
};
const osByDevice: Record<DeviceType, WeightedOption<string>[]> = {
desktop: [
{ value: 'Windows 10', weight: 0.5 },
{ value: 'Mac OS', weight: 0.3 },
{ value: 'Linux', weight: 0.12 },
{ value: 'Chrome OS', weight: 0.05 },
{ value: 'Windows 11', weight: 0.03 },
],
mobile: [
{ value: 'iOS', weight: 0.45 },
{ value: 'Android', weight: 0.55 },
],
tablet: [
{ value: 'iOS', weight: 0.75 },
{ value: 'Android', weight: 0.25 },
],
};
const screensByDevice: Record<DeviceType, string[]> = {
desktop: [
'1920x1080',
'2560x1440',
'1366x768',
'1440x900',
'3840x2160',
'1536x864',
'1680x1050',
'2560x1080',
],
mobile: ['390x844', '414x896', '375x812', '360x800', '428x926', '393x873', '412x915', '360x780'],
tablet: ['1024x768', '768x1024', '834x1194', '820x1180', '810x1080', '800x1280'],
};
export interface DeviceInfo {
device: DeviceType;
browser: string;
os: string;
screen: string;
}
export function getRandomDevice(): DeviceInfo {
const device = weightedRandom(deviceWeights);
const browser = weightedRandom(browsersByDevice[device]);
const os = weightedRandom(osByDevice[device]);
const screen = pickRandom(screensByDevice[device]);
return { device, browser, os, screen };
}

View file

@ -0,0 +1,144 @@
import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js';
interface GeoLocation {
country: string;
region: string;
city: string;
}
const countryWeights: WeightedOption<string>[] = [
{ value: 'US', weight: 0.35 },
{ value: 'GB', weight: 0.08 },
{ value: 'DE', weight: 0.06 },
{ value: 'FR', weight: 0.05 },
{ value: 'CA', weight: 0.04 },
{ value: 'AU', weight: 0.03 },
{ value: 'IN', weight: 0.08 },
{ value: 'BR', weight: 0.04 },
{ value: 'JP', weight: 0.03 },
{ value: 'NL', weight: 0.02 },
{ value: 'ES', weight: 0.02 },
{ value: 'IT', weight: 0.02 },
{ value: 'PL', weight: 0.02 },
{ value: 'SE', weight: 0.01 },
{ value: 'MX', weight: 0.02 },
{ value: 'KR', weight: 0.02 },
{ value: 'SG', weight: 0.01 },
{ value: 'ID', weight: 0.02 },
{ value: 'PH', weight: 0.01 },
{ value: 'TH', weight: 0.01 },
{ value: 'VN', weight: 0.01 },
{ value: 'RU', weight: 0.02 },
{ value: 'UA', weight: 0.01 },
{ value: 'ZA', weight: 0.01 },
{ value: 'NG', weight: 0.01 },
];
const regionsByCountry: Record<string, { region: string; city: string }[]> = {
US: [
{ region: 'CA', city: 'San Francisco' },
{ region: 'CA', city: 'Los Angeles' },
{ region: 'NY', city: 'New York' },
{ region: 'TX', city: 'Austin' },
{ region: 'TX', city: 'Houston' },
{ region: 'WA', city: 'Seattle' },
{ region: 'IL', city: 'Chicago' },
{ region: 'MA', city: 'Boston' },
{ region: 'CO', city: 'Denver' },
{ region: 'GA', city: 'Atlanta' },
{ region: 'FL', city: 'Miami' },
{ region: 'PA', city: 'Philadelphia' },
],
GB: [
{ region: 'ENG', city: 'London' },
{ region: 'ENG', city: 'Manchester' },
{ region: 'ENG', city: 'Birmingham' },
{ region: 'SCT', city: 'Edinburgh' },
{ region: 'ENG', city: 'Bristol' },
],
DE: [
{ region: 'BE', city: 'Berlin' },
{ region: 'BY', city: 'Munich' },
{ region: 'HH', city: 'Hamburg' },
{ region: 'HE', city: 'Frankfurt' },
{ region: 'NW', city: 'Cologne' },
],
FR: [
{ region: 'IDF', city: 'Paris' },
{ region: 'ARA', city: 'Lyon' },
{ region: 'PAC', city: 'Marseille' },
{ region: 'OCC', city: 'Toulouse' },
],
CA: [
{ region: 'ON', city: 'Toronto' },
{ region: 'BC', city: 'Vancouver' },
{ region: 'QC', city: 'Montreal' },
{ region: 'AB', city: 'Calgary' },
],
AU: [
{ region: 'NSW', city: 'Sydney' },
{ region: 'VIC', city: 'Melbourne' },
{ region: 'QLD', city: 'Brisbane' },
{ region: 'WA', city: 'Perth' },
],
IN: [
{ region: 'MH', city: 'Mumbai' },
{ region: 'KA', city: 'Bangalore' },
{ region: 'DL', city: 'New Delhi' },
{ region: 'TN', city: 'Chennai' },
{ region: 'TG', city: 'Hyderabad' },
],
BR: [
{ region: 'SP', city: 'Sao Paulo' },
{ region: 'RJ', city: 'Rio de Janeiro' },
{ region: 'MG', city: 'Belo Horizonte' },
],
JP: [
{ region: '13', city: 'Tokyo' },
{ region: '27', city: 'Osaka' },
{ region: '23', city: 'Nagoya' },
],
NL: [
{ region: 'NH', city: 'Amsterdam' },
{ region: 'ZH', city: 'Rotterdam' },
{ region: 'ZH', city: 'The Hague' },
],
};
const defaultRegions = [{ region: '', city: '' }];
export function getRandomGeo(): GeoLocation {
const country = weightedRandom(countryWeights);
const regions = regionsByCountry[country] || defaultRegions;
const { region, city } = pickRandom(regions);
return { country, region, city };
}
const languages: WeightedOption<string>[] = [
{ value: 'en-US', weight: 0.4 },
{ value: 'en-GB', weight: 0.08 },
{ value: 'de-DE', weight: 0.06 },
{ value: 'fr-FR', weight: 0.05 },
{ value: 'es-ES', weight: 0.05 },
{ value: 'pt-BR', weight: 0.04 },
{ value: 'ja-JP', weight: 0.03 },
{ value: 'zh-CN', weight: 0.05 },
{ value: 'ko-KR', weight: 0.02 },
{ value: 'ru-RU', weight: 0.02 },
{ value: 'it-IT', weight: 0.02 },
{ value: 'nl-NL', weight: 0.02 },
{ value: 'pl-PL', weight: 0.02 },
{ value: 'hi-IN', weight: 0.04 },
{ value: 'ar-SA', weight: 0.02 },
{ value: 'tr-TR', weight: 0.02 },
{ value: 'vi-VN', weight: 0.01 },
{ value: 'th-TH', weight: 0.01 },
{ value: 'id-ID', weight: 0.02 },
{ value: 'sv-SE', weight: 0.01 },
{ value: 'da-DK', weight: 0.01 },
];
export function getRandomLanguage(): string {
return weightedRandom(languages);
}

View file

@ -0,0 +1,163 @@
import { weightedRandom, pickRandom, randomInt, type WeightedOption } from '../utils.js';
export type ReferrerType = 'direct' | 'organic' | 'social' | 'paid' | 'referral';
export interface ReferrerInfo {
type: ReferrerType;
domain: string | null;
path: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmContent: string | null;
utmTerm: string | null;
gclid: string | null;
fbclid: string | null;
}
const referrerTypeWeights: WeightedOption<ReferrerType>[] = [
{ value: 'direct', weight: 0.4 },
{ value: 'organic', weight: 0.25 },
{ value: 'social', weight: 0.15 },
{ value: 'paid', weight: 0.1 },
{ value: 'referral', weight: 0.1 },
];
const searchEngines = [
{ domain: 'google.com', path: '/search' },
{ domain: 'bing.com', path: '/search' },
{ domain: 'duckduckgo.com', path: '/' },
{ domain: 'yahoo.com', path: '/search' },
{ domain: 'baidu.com', path: '/s' },
];
const socialPlatforms = [
{ domain: 'twitter.com', path: null },
{ domain: 'x.com', path: null },
{ domain: 'linkedin.com', path: '/feed' },
{ domain: 'facebook.com', path: null },
{ domain: 'reddit.com', path: '/r/programming' },
{ domain: 'news.ycombinator.com', path: '/item' },
{ domain: 'threads.net', path: null },
{ domain: 'bsky.app', path: null },
];
const referralSites = [
{ domain: 'medium.com', path: '/@author/article' },
{ domain: 'dev.to', path: '/post' },
{ domain: 'hashnode.com', path: '/blog' },
{ domain: 'techcrunch.com', path: '/article' },
{ domain: 'producthunt.com', path: '/posts' },
{ domain: 'indiehackers.com', path: '/post' },
];
interface PaidCampaign {
source: string;
medium: string;
campaign: string;
useGclid?: boolean;
useFbclid?: boolean;
}
const paidCampaigns: PaidCampaign[] = [
{ source: 'google', medium: 'cpc', campaign: 'brand_search', useGclid: true },
{ source: 'google', medium: 'cpc', campaign: 'product_awareness', useGclid: true },
{ source: 'facebook', medium: 'paid_social', campaign: 'retargeting', useFbclid: true },
{ source: 'facebook', medium: 'paid_social', campaign: 'lookalike', useFbclid: true },
{ source: 'linkedin', medium: 'cpc', campaign: 'b2b_targeting' },
{ source: 'twitter', medium: 'paid_social', campaign: 'launch_promo' },
];
const organicCampaigns = [
{ source: 'newsletter', medium: 'email', campaign: 'weekly_digest' },
{ source: 'newsletter', medium: 'email', campaign: 'product_update' },
{ source: 'partner', medium: 'referral', campaign: 'integration_launch' },
];
function generateClickId(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function getRandomReferrer(): ReferrerInfo {
const type = weightedRandom(referrerTypeWeights);
const result: ReferrerInfo = {
type,
domain: null,
path: null,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
utmTerm: null,
gclid: null,
fbclid: null,
};
switch (type) {
case 'direct':
// No referrer data
break;
case 'organic': {
const engine = pickRandom(searchEngines);
result.domain = engine.domain;
result.path = engine.path;
break;
}
case 'social': {
const platform = pickRandom(socialPlatforms);
result.domain = platform.domain;
result.path = platform.path;
// Some social traffic has UTM params
if (Math.random() < 0.3) {
result.utmSource = platform.domain.replace('.com', '').replace('.net', '');
result.utmMedium = 'social';
}
break;
}
case 'paid': {
const campaign = pickRandom(paidCampaigns);
result.utmSource = campaign.source;
result.utmMedium = campaign.medium;
result.utmCampaign = campaign.campaign;
result.utmContent = `ad_${randomInt(1, 5)}`;
if (campaign.useGclid) {
result.gclid = generateClickId();
result.domain = 'google.com';
result.path = '/search';
} else if (campaign.useFbclid) {
result.fbclid = generateClickId();
result.domain = 'facebook.com';
result.path = null;
}
break;
}
case 'referral': {
// Mix of pure referrals and organic campaigns
if (Math.random() < 0.6) {
const site = pickRandom(referralSites);
result.domain = site.domain;
result.path = site.path;
} else {
const campaign = pickRandom(organicCampaigns);
result.utmSource = campaign.source;
result.utmMedium = campaign.medium;
result.utmCampaign = campaign.campaign;
}
break;
}
}
return result;
}

View file

@ -0,0 +1,69 @@
import { weightedRandom, randomInt, type WeightedOption } from '../utils.js';
const hourlyWeights: WeightedOption<number>[] = [
{ value: 0, weight: 0.02 },
{ value: 1, weight: 0.01 },
{ value: 2, weight: 0.01 },
{ value: 3, weight: 0.01 },
{ value: 4, weight: 0.01 },
{ value: 5, weight: 0.02 },
{ value: 6, weight: 0.03 },
{ value: 7, weight: 0.05 },
{ value: 8, weight: 0.07 },
{ value: 9, weight: 0.08 },
{ value: 10, weight: 0.09 },
{ value: 11, weight: 0.08 },
{ value: 12, weight: 0.07 },
{ value: 13, weight: 0.08 },
{ value: 14, weight: 0.09 },
{ value: 15, weight: 0.08 },
{ value: 16, weight: 0.07 },
{ value: 17, weight: 0.06 },
{ value: 18, weight: 0.05 },
{ value: 19, weight: 0.04 },
{ value: 20, weight: 0.03 },
{ value: 21, weight: 0.03 },
{ value: 22, weight: 0.02 },
{ value: 23, weight: 0.02 },
];
const dayOfWeekWeights: WeightedOption<number>[] = [
{ value: 0, weight: 0.08 }, // Sunday
{ value: 1, weight: 0.16 }, // Monday
{ value: 2, weight: 0.17 }, // Tuesday
{ value: 3, weight: 0.17 }, // Wednesday
{ value: 4, weight: 0.16 }, // Thursday
{ value: 5, weight: 0.15 }, // Friday
{ value: 6, weight: 0.11 }, // Saturday
];
export function getWeightedHour(): number {
return weightedRandom(hourlyWeights);
}
export function getDayOfWeekMultiplier(dayOfWeek: number): number {
const weight = dayOfWeekWeights.find(d => d.value === dayOfWeek)?.weight ?? 0.14;
return weight / 0.14; // Normalize around 1.0
}
export function generateTimestampForDay(day: Date): Date {
const hour = getWeightedHour();
const minute = randomInt(0, 59);
const second = randomInt(0, 59);
const millisecond = randomInt(0, 999);
const timestamp = new Date(day);
timestamp.setHours(hour, minute, second, millisecond);
return timestamp;
}
export function getSessionCountForDay(baseCount: number, day: Date): number {
const dayOfWeek = day.getDay();
const multiplier = getDayOfWeekMultiplier(dayOfWeek);
// Add some random variance (±20%)
const variance = 0.8 + Math.random() * 0.4;
return Math.round(baseCount * multiplier * variance);
}

View file

@ -0,0 +1,191 @@
import { uuid, addSeconds, randomInt } from '../utils.js';
import { getRandomReferrer } from '../distributions/referrers.js';
import type { SessionData } from './sessions.js';
export const EVENT_TYPE = {
pageView: 1,
customEvent: 2,
} as const;
export interface PageConfig {
path: string;
title: string;
weight: number;
avgTimeOnPage: number;
}
export interface CustomEventConfig {
name: string;
weight: number;
pages?: string[];
data?: Record<string, string[] | number[]>;
}
export interface JourneyConfig {
pages: string[];
weight: number;
}
export interface EventData {
id: string;
websiteId: string;
sessionId: string;
visitId: string;
eventType: number;
urlPath: string;
urlQuery: string | null;
pageTitle: string | null;
hostname: string;
referrerDomain: string | null;
referrerPath: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmContent: string | null;
utmTerm: string | null;
gclid: string | null;
fbclid: string | null;
eventName: string | null;
tag: string | null;
createdAt: Date;
}
export interface EventDataEntry {
id: string;
websiteId: string;
websiteEventId: string;
dataKey: string;
stringValue: string | null;
numberValue: number | null;
dateValue: Date | null;
dataType: number;
createdAt: Date;
}
export interface SiteConfig {
hostname: string;
pages: PageConfig[];
journeys: JourneyConfig[];
customEvents: CustomEventConfig[];
}
function getPageTitle(pages: PageConfig[], path: string): string | null {
const page = pages.find(p => p.path === path);
return page?.title ?? null;
}
function getPageTimeOnPage(pages: PageConfig[], path: string): number {
const page = pages.find(p => p.path === path);
return page?.avgTimeOnPage ?? 30;
}
export function generateEventsForSession(
session: SessionData,
siteConfig: SiteConfig,
journey: string[],
): { events: EventData[]; eventDataEntries: EventDataEntry[] } {
const events: EventData[] = [];
const eventDataEntries: EventDataEntry[] = [];
const visitId = uuid();
let currentTime = session.createdAt;
const referrer = getRandomReferrer();
for (let i = 0; i < journey.length; i++) {
const pagePath = journey[i];
const isFirstPage = i === 0;
const eventId = uuid();
const pageTitle = getPageTitle(siteConfig.pages, pagePath);
events.push({
id: eventId,
websiteId: session.websiteId,
sessionId: session.id,
visitId,
eventType: EVENT_TYPE.pageView,
urlPath: pagePath,
urlQuery: null,
pageTitle,
hostname: siteConfig.hostname,
referrerDomain: isFirstPage ? referrer.domain : null,
referrerPath: isFirstPage ? referrer.path : null,
utmSource: isFirstPage ? referrer.utmSource : null,
utmMedium: isFirstPage ? referrer.utmMedium : null,
utmCampaign: isFirstPage ? referrer.utmCampaign : null,
utmContent: isFirstPage ? referrer.utmContent : null,
utmTerm: isFirstPage ? referrer.utmTerm : null,
gclid: isFirstPage ? referrer.gclid : null,
fbclid: isFirstPage ? referrer.fbclid : null,
eventName: null,
tag: null,
createdAt: currentTime,
});
// Check for custom events on this page
for (const customEvent of siteConfig.customEvents) {
// Check if this event can occur on this page
if (customEvent.pages && !customEvent.pages.includes(pagePath)) {
continue;
}
// Random chance based on weight
if (Math.random() < customEvent.weight) {
currentTime = addSeconds(currentTime, randomInt(2, 15));
const customEventId = uuid();
events.push({
id: customEventId,
websiteId: session.websiteId,
sessionId: session.id,
visitId,
eventType: EVENT_TYPE.customEvent,
urlPath: pagePath,
urlQuery: null,
pageTitle,
hostname: siteConfig.hostname,
referrerDomain: null,
referrerPath: null,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
utmTerm: null,
gclid: null,
fbclid: null,
eventName: customEvent.name,
tag: null,
createdAt: currentTime,
});
// Generate event data if configured
if (customEvent.data) {
for (const [key, values] of Object.entries(customEvent.data)) {
const value = values[Math.floor(Math.random() * values.length)];
const isNumber = typeof value === 'number';
eventDataEntries.push({
id: uuid(),
websiteId: session.websiteId,
websiteEventId: customEventId,
dataKey: key,
stringValue: isNumber ? null : String(value),
numberValue: isNumber ? value : null,
dateValue: null,
dataType: isNumber ? 2 : 1, // 1 = string, 2 = number
createdAt: currentTime,
});
}
}
}
}
// Time spent on page before navigating
const timeOnPage = getPageTimeOnPage(siteConfig.pages, pagePath);
const variance = Math.floor(timeOnPage * 0.5);
const actualTime = timeOnPage + randomInt(-variance, variance);
currentTime = addSeconds(currentTime, Math.max(5, actualTime));
}
return { events, eventDataEntries };
}

View file

@ -0,0 +1,65 @@
import { uuid, randomFloat } from '../utils.js';
import type { EventData } from './events.js';
export interface RevenueConfig {
eventName: string;
minAmount: number;
maxAmount: number;
currency: string;
weight: number;
}
export interface RevenueData {
id: string;
websiteId: string;
sessionId: string;
eventId: string;
eventName: string;
currency: string;
revenue: number;
createdAt: Date;
}
export function generateRevenue(event: EventData, config: RevenueConfig): RevenueData | null {
if (event.eventName !== config.eventName) {
return null;
}
if (Math.random() > config.weight) {
return null;
}
const revenue = randomFloat(config.minAmount, config.maxAmount);
return {
id: uuid(),
websiteId: event.websiteId,
sessionId: event.sessionId,
eventId: event.id,
eventName: event.eventName!,
currency: config.currency,
revenue: Math.round(revenue * 100) / 100, // Round to 2 decimal places
createdAt: event.createdAt,
};
}
export function generateRevenueForEvents(
events: EventData[],
configs: RevenueConfig[],
): RevenueData[] {
const revenueEntries: RevenueData[] = [];
for (const event of events) {
if (!event.eventName) continue;
for (const config of configs) {
const revenue = generateRevenue(event, config);
if (revenue) {
revenueEntries.push(revenue);
break; // Only one revenue per event
}
}
}
return revenueEntries;
}

View file

@ -0,0 +1,52 @@
import { uuid } from '../utils.js';
import { getRandomDevice } from '../distributions/devices.js';
import { getRandomGeo, getRandomLanguage } from '../distributions/geographic.js';
import { generateTimestampForDay } from '../distributions/temporal.js';
export interface SessionData {
id: string;
websiteId: string;
browser: string;
os: string;
device: string;
screen: string;
language: string;
country: string;
region: string;
city: string;
createdAt: Date;
}
export function createSession(websiteId: string, day: Date): SessionData {
const deviceInfo = getRandomDevice();
const geo = getRandomGeo();
const language = getRandomLanguage();
const createdAt = generateTimestampForDay(day);
return {
id: uuid(),
websiteId,
browser: deviceInfo.browser,
os: deviceInfo.os,
device: deviceInfo.device,
screen: deviceInfo.screen,
language,
country: geo.country,
region: geo.region,
city: geo.city,
createdAt,
};
}
export function createSessions(websiteId: string, day: Date, count: number): SessionData[] {
const sessions: SessionData[] = [];
for (let i = 0; i < count; i++) {
sessions.push(createSession(websiteId, day));
}
// Sort by createdAt to maintain chronological order
sessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
return sessions;
}

378
scripts/seed/index.ts Normal file
View file

@ -0,0 +1,378 @@
/* eslint-disable no-console */
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient, Prisma } from '../../src/generated/prisma/client.js';
import { uuid, generateDatesBetween, subDays, formatNumber, progressBar } from './utils.js';
import { createSessions, type SessionData } from './generators/sessions.js';
import {
generateEventsForSession,
type EventData,
type EventDataEntry,
} from './generators/events.js';
import {
generateRevenueForEvents,
type RevenueData,
type RevenueConfig,
} from './generators/revenue.js';
import { getSessionCountForDay } from './distributions/temporal.js';
import {
BLOG_WEBSITE_NAME,
BLOG_WEBSITE_DOMAIN,
BLOG_SESSIONS_PER_DAY,
getBlogSiteConfig,
getBlogJourney,
} from './sites/blog.js';
import {
SAAS_WEBSITE_NAME,
SAAS_WEBSITE_DOMAIN,
SAAS_SESSIONS_PER_DAY,
getSaasSiteConfig,
getSaasJourney,
saasRevenueConfigs,
} from './sites/saas.js';
const BATCH_SIZE = 1000;
type SessionCreateInput = Prisma.SessionCreateManyInput;
type WebsiteEventCreateInput = Prisma.WebsiteEventCreateManyInput;
type EventDataCreateInput = Prisma.EventDataCreateManyInput;
type RevenueCreateInput = Prisma.RevenueCreateManyInput;
export interface SeedConfig {
days: number;
clear: boolean;
verbose: boolean;
}
export interface SeedResult {
websites: number;
sessions: number;
events: number;
eventData: number;
revenue: number;
}
async function batchInsertSessions(
prisma: PrismaClient,
data: SessionCreateInput[],
verbose: boolean,
): Promise<void> {
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE);
await prisma.session.createMany({ data: batch, skipDuplicates: true });
if (verbose) {
console.log(
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} session records`,
);
}
}
}
async function batchInsertEvents(
prisma: PrismaClient,
data: WebsiteEventCreateInput[],
verbose: boolean,
): Promise<void> {
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE);
await prisma.websiteEvent.createMany({ data: batch, skipDuplicates: true });
if (verbose) {
console.log(
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} event records`,
);
}
}
}
async function batchInsertEventData(
prisma: PrismaClient,
data: EventDataCreateInput[],
verbose: boolean,
): Promise<void> {
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE);
await prisma.eventData.createMany({ data: batch, skipDuplicates: true });
if (verbose) {
console.log(
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} eventData records`,
);
}
}
}
async function batchInsertRevenue(
prisma: PrismaClient,
data: RevenueCreateInput[],
verbose: boolean,
): Promise<void> {
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE);
await prisma.revenue.createMany({ data: batch, skipDuplicates: true });
if (verbose) {
console.log(
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} revenue records`,
);
}
}
}
async function findAdminUser(prisma: PrismaClient): Promise<string> {
const adminUser = await prisma.user.findFirst({
where: { role: 'admin' },
select: { id: true },
});
if (!adminUser) {
throw new Error(
'No admin user found in the database.\n' +
'Please ensure you have run the initial setup and created an admin user.\n' +
'The default admin user is created during first build (username: admin, password: umami).',
);
}
return adminUser.id;
}
async function createWebsite(
prisma: PrismaClient,
name: string,
domain: string,
adminUserId: string,
): Promise<string> {
const websiteId = uuid();
await prisma.website.create({
data: {
id: websiteId,
name,
domain,
userId: adminUserId,
createdBy: adminUserId,
},
});
return websiteId;
}
async function clearDemoData(prisma: PrismaClient): Promise<void> {
console.log('Clearing existing demo data...');
const demoWebsites = await prisma.website.findMany({
where: {
OR: [{ name: BLOG_WEBSITE_NAME }, { name: SAAS_WEBSITE_NAME }],
},
select: { id: true },
});
const websiteIds = demoWebsites.map(w => w.id);
if (websiteIds.length === 0) {
console.log(' No existing demo websites found');
return;
}
console.log(` Found ${websiteIds.length} demo website(s)`);
// Delete in correct order due to foreign key constraints
await prisma.revenue.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.eventData.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.sessionData.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.websiteEvent.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.session.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.segment.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.report.deleteMany({ where: { websiteId: { in: websiteIds } } });
await prisma.website.deleteMany({ where: { id: { in: websiteIds } } });
console.log(' Cleared existing demo data');
}
interface SiteGeneratorConfig {
name: string;
domain: string;
sessionsPerDay: number;
getSiteConfig: () => ReturnType<typeof getBlogSiteConfig>;
getJourney: () => string[];
revenueConfigs?: RevenueConfig[];
}
async function generateSiteData(
prisma: PrismaClient,
config: SiteGeneratorConfig,
days: Date[],
adminUserId: string,
verbose: boolean,
): Promise<{ sessions: number; events: number; eventData: number; revenue: number }> {
console.log(`\nGenerating data for ${config.name}...`);
const websiteId = await createWebsite(prisma, config.name, config.domain, adminUserId);
console.log(` Created website: ${config.name} (${websiteId})`);
const siteConfig = config.getSiteConfig();
const allSessions: SessionData[] = [];
const allEvents: EventData[] = [];
const allEventData: EventDataEntry[] = [];
const allRevenue: RevenueData[] = [];
for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
const day = days[dayIndex];
const sessionCount = getSessionCountForDay(config.sessionsPerDay, day);
const sessions = createSessions(websiteId, day, sessionCount);
for (const session of sessions) {
const journey = config.getJourney();
const { events, eventDataEntries } = generateEventsForSession(session, siteConfig, journey);
allSessions.push(session);
allEvents.push(...events);
allEventData.push(...eventDataEntries);
if (config.revenueConfigs) {
const revenueEntries = generateRevenueForEvents(events, config.revenueConfigs);
allRevenue.push(...revenueEntries);
}
}
// Show progress (every day in verbose mode, otherwise every 2 days)
const shouldShowProgress = verbose || dayIndex % 2 === 0 || dayIndex === days.length - 1;
if (shouldShowProgress) {
process.stdout.write(
`\r ${progressBar(dayIndex + 1, days.length)} Day ${dayIndex + 1}/${days.length}`,
);
}
}
console.log(''); // New line after progress bar
// Batch insert all data
console.log(` Inserting ${formatNumber(allSessions.length)} sessions...`);
await batchInsertSessions(prisma, allSessions as SessionCreateInput[], verbose);
console.log(` Inserting ${formatNumber(allEvents.length)} events...`);
await batchInsertEvents(prisma, allEvents as WebsiteEventCreateInput[], verbose);
if (allEventData.length > 0) {
console.log(` Inserting ${formatNumber(allEventData.length)} event data entries...`);
await batchInsertEventData(prisma, allEventData as EventDataCreateInput[], verbose);
}
if (allRevenue.length > 0) {
console.log(` Inserting ${formatNumber(allRevenue.length)} revenue entries...`);
await batchInsertRevenue(prisma, allRevenue as RevenueCreateInput[], verbose);
}
return {
sessions: allSessions.length,
events: allEvents.length,
eventData: allEventData.length,
revenue: allRevenue.length,
};
}
function createPrismaClient(): PrismaClient {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error(
'DATABASE_URL environment variable is not set.\n' +
'Please set DATABASE_URL in your .env file or environment.\n' +
'Example: DATABASE_URL=postgresql://user:password@localhost:5432/umami',
);
}
let schema: string | undefined;
try {
const connectionUrl = new URL(url);
schema = connectionUrl.searchParams.get('schema') ?? undefined;
} catch {
throw new Error(
'DATABASE_URL is not a valid URL.\n' +
'Expected format: postgresql://user:password@host:port/database\n' +
`Received: ${url.substring(0, 30)}...`,
);
}
const adapter = new PrismaPg({ connectionString: url }, { schema });
return new PrismaClient({
adapter,
errorFormat: 'pretty',
});
}
export async function seed(config: SeedConfig): Promise<SeedResult> {
const prisma = createPrismaClient();
try {
const endDate = new Date();
const startDate = subDays(endDate, config.days);
const days = generateDatesBetween(startDate, endDate);
console.log(`\nSeed Configuration:`);
console.log(
` Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`,
);
console.log(` Days: ${days.length}`);
console.log(` Clear existing: ${config.clear}`);
if (config.clear) {
await clearDemoData(prisma);
}
// Find admin user to own the demo websites
const adminUserId = await findAdminUser(prisma);
console.log(` Using admin user: ${adminUserId}`);
// Generate Blog site (low traffic)
const blogResults = await generateSiteData(
prisma,
{
name: BLOG_WEBSITE_NAME,
domain: BLOG_WEBSITE_DOMAIN,
sessionsPerDay: BLOG_SESSIONS_PER_DAY,
getSiteConfig: getBlogSiteConfig,
getJourney: getBlogJourney,
},
days,
adminUserId,
config.verbose,
);
// Generate SaaS site (high traffic)
const saasResults = await generateSiteData(
prisma,
{
name: SAAS_WEBSITE_NAME,
domain: SAAS_WEBSITE_DOMAIN,
sessionsPerDay: SAAS_SESSIONS_PER_DAY,
getSiteConfig: getSaasSiteConfig,
getJourney: getSaasJourney,
revenueConfigs: saasRevenueConfigs,
},
days,
adminUserId,
config.verbose,
);
const result: SeedResult = {
websites: 2,
sessions: blogResults.sessions + saasResults.sessions,
events: blogResults.events + saasResults.events,
eventData: blogResults.eventData + saasResults.eventData,
revenue: blogResults.revenue + saasResults.revenue,
};
console.log(`\n${'─'.repeat(50)}`);
console.log(`Seed Complete!`);
console.log(`${'─'.repeat(50)}`);
console.log(` Websites: ${formatNumber(result.websites)}`);
console.log(` Sessions: ${formatNumber(result.sessions)}`);
console.log(` Events: ${formatNumber(result.events)}`);
console.log(` Event Data: ${formatNumber(result.eventData)}`);
console.log(` Revenue: ${formatNumber(result.revenue)}`);
console.log(`${'─'.repeat(50)}\n`);
return result;
} finally {
await prisma.$disconnect();
}
}

108
scripts/seed/sites/blog.ts Normal file
View file

@ -0,0 +1,108 @@
import { weightedRandom, type WeightedOption } from '../utils.js';
import type {
SiteConfig,
JourneyConfig,
PageConfig,
CustomEventConfig,
} from '../generators/events.js';
export const BLOG_WEBSITE_NAME = 'Demo Blog';
export const BLOG_WEBSITE_DOMAIN = 'blog.example.com';
const blogPosts = [
'getting-started-with-analytics',
'privacy-first-tracking',
'understanding-your-visitors',
'improving-page-performance',
'seo-best-practices',
'content-marketing-guide',
'building-audience-trust',
'data-driven-decisions',
];
export const blogPages: PageConfig[] = [
{ path: '/', title: 'Demo Blog - Home', weight: 0.25, avgTimeOnPage: 30 },
{ path: '/blog', title: 'Blog Posts', weight: 0.2, avgTimeOnPage: 45 },
{ path: '/about', title: 'About Us', weight: 0.1, avgTimeOnPage: 60 },
{ path: '/contact', title: 'Contact', weight: 0.05, avgTimeOnPage: 45 },
...blogPosts.map(slug => ({
path: `/blog/${slug}`,
title: slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' '),
weight: 0.05,
avgTimeOnPage: 180,
})),
];
export const blogJourneys: JourneyConfig[] = [
// Direct to blog post (organic search)
{ pages: ['/blog/getting-started-with-analytics'], weight: 0.15 },
{ pages: ['/blog/privacy-first-tracking'], weight: 0.12 },
{ pages: ['/blog/understanding-your-visitors'], weight: 0.1 },
// Homepage bounces
{ pages: ['/'], weight: 0.15 },
// Homepage to blog listing
{ pages: ['/', '/blog'], weight: 0.1 },
// Homepage to blog post
{ pages: ['/', '/blog', '/blog/seo-best-practices'], weight: 0.08 },
{ pages: ['/', '/blog', '/blog/content-marketing-guide'], weight: 0.08 },
// About page visits
{ pages: ['/', '/about'], weight: 0.07 },
{ pages: ['/', '/about', '/contact'], weight: 0.05 },
// Blog post to another
{ pages: ['/blog/improving-page-performance', '/blog/data-driven-decisions'], weight: 0.05 },
// Longer sessions
{ pages: ['/', '/blog', '/blog/building-audience-trust', '/about'], weight: 0.05 },
];
export const blogCustomEvents: CustomEventConfig[] = [
{
name: 'newsletter_signup',
weight: 0.03,
pages: ['/', '/blog'],
},
{
name: 'share_click',
weight: 0.05,
pages: blogPosts.map(slug => `/blog/${slug}`),
data: {
platform: ['twitter', 'linkedin', 'facebook', 'copy_link'],
},
},
{
name: 'scroll_depth',
weight: 0.2,
pages: blogPosts.map(slug => `/blog/${slug}`),
data: {
depth: [25, 50, 75, 100],
},
},
];
export function getBlogSiteConfig(): SiteConfig {
return {
hostname: BLOG_WEBSITE_DOMAIN,
pages: blogPages,
journeys: blogJourneys,
customEvents: blogCustomEvents,
};
}
export function getBlogJourney(): string[] {
const journeyWeights: WeightedOption<string[]>[] = blogJourneys.map(j => ({
value: j.pages,
weight: j.weight,
}));
return weightedRandom(journeyWeights);
}
export const BLOG_SESSIONS_PER_DAY = 3; // ~90 sessions per month

185
scripts/seed/sites/saas.ts Normal file
View file

@ -0,0 +1,185 @@
import { weightedRandom, type WeightedOption } from '../utils.js';
import type {
SiteConfig,
JourneyConfig,
PageConfig,
CustomEventConfig,
} from '../generators/events.js';
import type { RevenueConfig } from '../generators/revenue.js';
export const SAAS_WEBSITE_NAME = 'Demo SaaS';
export const SAAS_WEBSITE_DOMAIN = 'app.example.com';
const docsSections = [
'getting-started',
'installation',
'configuration',
'api-reference',
'integrations',
];
const blogPosts = [
'announcing-v2',
'customer-success-story',
'product-roadmap',
'security-best-practices',
];
export const saasPages: PageConfig[] = [
{ path: '/', title: 'Demo SaaS - Analytics Made Simple', weight: 0.2, avgTimeOnPage: 45 },
{ path: '/features', title: 'Features', weight: 0.15, avgTimeOnPage: 90 },
{ path: '/pricing', title: 'Pricing', weight: 0.15, avgTimeOnPage: 120 },
{ path: '/docs', title: 'Documentation', weight: 0.1, avgTimeOnPage: 60 },
{ path: '/blog', title: 'Blog', weight: 0.05, avgTimeOnPage: 45 },
{ path: '/signup', title: 'Sign Up', weight: 0.08, avgTimeOnPage: 90 },
{ path: '/login', title: 'Login', weight: 0.05, avgTimeOnPage: 30 },
{ path: '/demo', title: 'Request Demo', weight: 0.05, avgTimeOnPage: 60 },
...docsSections.map(slug => ({
path: `/docs/${slug}`,
title: `Docs: ${slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')}`,
weight: 0.02,
avgTimeOnPage: 180,
})),
...blogPosts.map(slug => ({
path: `/blog/${slug}`,
title: slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' '),
weight: 0.02,
avgTimeOnPage: 150,
})),
];
export const saasJourneys: JourneyConfig[] = [
// Conversion funnel
{ pages: ['/', '/features', '/pricing', '/signup'], weight: 0.12 },
{ pages: ['/', '/pricing', '/signup'], weight: 0.1 },
{ pages: ['/pricing', '/signup'], weight: 0.08 },
// Feature exploration
{ pages: ['/', '/features'], weight: 0.1 },
{ pages: ['/', '/features', '/pricing'], weight: 0.08 },
// Documentation users
{ pages: ['/docs', '/docs/getting-started'], weight: 0.08 },
{ pages: ['/docs/getting-started', '/docs/installation', '/docs/configuration'], weight: 0.06 },
{ pages: ['/docs/api-reference'], weight: 0.05 },
// Blog readers
{ pages: ['/blog/announcing-v2'], weight: 0.05 },
{ pages: ['/blog/customer-success-story'], weight: 0.04 },
// Returning users
{ pages: ['/login'], weight: 0.08 },
// Bounces
{ pages: ['/'], weight: 0.08 },
{ pages: ['/pricing'], weight: 0.05 },
// Demo requests
{ pages: ['/', '/demo'], weight: 0.03 },
];
export const saasCustomEvents: CustomEventConfig[] = [
{
name: 'signup_started',
weight: 0.6,
pages: ['/signup'],
data: {
plan: ['free', 'pro', 'enterprise'],
},
},
{
name: 'signup_completed',
weight: 0.3,
pages: ['/signup'],
data: {
plan: ['free', 'pro', 'enterprise'],
method: ['email', 'google', 'github'],
},
},
{
name: 'purchase',
weight: 0.15,
pages: ['/signup', '/pricing'],
data: {
plan: ['pro', 'enterprise'],
billing: ['monthly', 'annual'],
revenue: [29, 49, 99, 299],
currency: ['USD'],
},
},
{
name: 'demo_requested',
weight: 0.5,
pages: ['/demo'],
data: {
company_size: ['1-10', '11-50', '51-200', '200+'],
},
},
{
name: 'feature_viewed',
weight: 0.3,
pages: ['/features'],
data: {
feature: ['analytics', 'reports', 'api', 'integrations', 'privacy'],
},
},
{
name: 'cta_click',
weight: 0.15,
pages: ['/', '/features', '/pricing'],
data: {
button: ['hero_signup', 'nav_signup', 'pricing_cta', 'footer_cta'],
},
},
{
name: 'docs_search',
weight: 0.2,
pages: ['/docs', ...docsSections.map(s => `/docs/${s}`)],
data: {
query_type: ['api', 'setup', 'integration', 'troubleshooting'],
},
},
];
export const saasRevenueConfigs: RevenueConfig[] = [
{
eventName: 'purchase',
minAmount: 29,
maxAmount: 29,
currency: 'USD',
weight: 0.7, // 70% Pro plan
},
{
eventName: 'purchase',
minAmount: 299,
maxAmount: 299,
currency: 'USD',
weight: 0.3, // 30% Enterprise
},
];
export function getSaasSiteConfig(): SiteConfig {
return {
hostname: SAAS_WEBSITE_DOMAIN,
pages: saasPages,
journeys: saasJourneys,
customEvents: saasCustomEvents,
};
}
export function getSaasJourney(): string[] {
const journeyWeights: WeightedOption<string[]>[] = saasJourneys.map(j => ({
value: j.pages,
weight: j.weight,
}));
return weightedRandom(journeyWeights);
}
export const SAAS_SESSIONS_PER_DAY = 500;

85
scripts/seed/utils.ts Normal file
View file

@ -0,0 +1,85 @@
import { v4 as uuidv4 } from 'uuid';
export interface WeightedOption<T> {
value: T;
weight: number;
}
export function weightedRandom<T>(options: WeightedOption<T>[]): T {
const totalWeight = options.reduce((sum, opt) => sum + opt.weight, 0);
let random = Math.random() * totalWeight;
for (const option of options) {
random -= option.weight;
if (random <= 0) {
return option.value;
}
}
return options[options.length - 1].value;
}
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function randomFloat(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
export function pickRandom<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
export function shuffleArray<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function uuid(): string {
return uuidv4();
}
export function generateDatesBetween(startDate: Date, endDate: Date): Date[] {
const dates: Date[] = [];
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
while (current <= endDate) {
dates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return dates;
}
export function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
export function addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60 * 1000);
}
export function addSeconds(date: Date, seconds: number): Date {
return new Date(date.getTime() + seconds * 1000);
}
export function subDays(date: Date, days: number): Date {
return new Date(date.getTime() - days * 24 * 60 * 60 * 1000);
}
export function formatNumber(num: number): string {
return num.toLocaleString();
}
export function progressBar(current: number, total: number, width = 30): string {
const percent = current / total;
const filled = Math.round(width * percent);
const empty = width - filled;
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${Math.round(percent * 100)}%`;
}

View file

@ -21,7 +21,11 @@ export function LinksTable(props: DataTableProps) {
<DataColumn id="slug" label={formatMessage(labels.link)}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>

View file

@ -11,7 +11,7 @@ export function LinkHeader() {
return (
<PageHeader title={link.name} description={link.url} icon={<Link />} marginBottom="3">
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false}>
<Icon>
<ExternalLink />
</Icon>

View file

@ -21,7 +21,11 @@ export function PixelsTable(props: DataTableProps) {
<DataColumn id="url" label="URL">
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>

View file

@ -13,12 +13,19 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings');
const { formatMessage, labels } = useMessages();
if (isSettings) {
return null;
}
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3">
<PageHeader
title={website.name}
icon={<Favicon domain={website.domain} />}
marginBottom="3"
titleHref={renderUrl(`/websites/${website.id}`, false)}
>
<Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} />
@ -29,7 +36,7 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<Icon>
<Edit />
</Icon>
<Text>Edit</Text>
<Text>{formatMessage(labels.edit)}</Text>
</LinkButton>
</Row>
)}

View file

@ -17,6 +17,7 @@ export async function POST(request: Request) {
const errors = [];
let index = 0;
let cache = null;
for (const data of body) {
// Recreate a fresh Request since `new Request(request)` will have the following error:
// > Cannot read private member #state from an object whose class did not declare it
@ -33,9 +34,12 @@ export async function POST(request: Request) {
});
const response = await send.POST(newRequest);
const responseJson = await response.json();
if (!response.ok) {
errors.push({ index, response: await response.json() });
errors.push({ index, response: responseJson });
} else {
cache ??= responseJson.cache;
}
index++;
@ -46,6 +50,7 @@ export async function POST(request: Request) {
processed: body.length - errors.length,
errors: errors.length,
details: errors,
cache,
});
} catch (e) {
return serverError(e);

View file

@ -41,6 +41,9 @@ const schema = z.object({
userAgent: z.string().optional(),
timestamp: z.coerce.number().int().optional(),
id: z.string().optional(),
browser: z.string().optional(),
os: z.string().optional(),
device: z.string().optional(),
})
.refine(
data => {

View file

@ -1,8 +1,13 @@
import { Icon, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import Link, { type LinkProps } from 'next/link';
import type { ReactNode } from 'react';
import { ExternalLink as LinkIcon } from '@/components/icons';
export function ExternalLink({ href, children, ...props }) {
export function ExternalLink({
href,
children,
...props
}: LinkProps & { href: string; children: ReactNode }) {
return (
<Row alignItems="center" overflow="hidden" gap>
<Text title={href} truncate>

View file

@ -1,5 +1,6 @@
import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { LinkButton } from './LinkButton';
export function PageHeader({
title,
@ -7,6 +8,7 @@ export function PageHeader({
label,
icon,
showBorder = true,
titleHref,
children,
}: {
title: string;
@ -14,6 +16,7 @@ export function PageHeader({
label?: ReactNode;
icon?: ReactNode;
showBorder?: boolean;
titleHref?: string;
allowEdit?: boolean;
className?: string;
children?: ReactNode;
@ -33,7 +36,13 @@ export function PageHeader({
{icon}
</Icon>
)}
{title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>}
{title && titleHref ? (
<LinkButton href={titleHref} variant="quiet">
<Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
</LinkButton>
) : (
title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
)}
</Row>
{description && (
<Text color="muted" truncate style={{ maxWidth: 600 }} title={description}>

View file

@ -1,5 +1,6 @@
import { StatusLight, Text } from '@umami/react-zen';
import { useMemo } from 'react';
import { LinkButton } from '@/components/common/LinkButton';
import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
export function ActiveUsers({
@ -27,10 +28,12 @@ export function ActiveUsers({
}
return (
<StatusLight variant="success">
<Text size="2" weight="medium">
{count} {formatMessage(labels.online)}
</Text>
</StatusLight>
<LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet">
<StatusLight variant="success">
<Text size="2" weight="medium">
{count} {formatMessage(labels.online)}
</Text>
</StatusLight>
</LinkButton>
);
}

View file

@ -289,6 +289,7 @@
"label.websites": "المواقع",
"label.window": "النافذة",
"label.yesterday": "الأمس",
"label.behavior": "السلوك",
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
"message.bad-request": "Bad request",

View file

@ -20,6 +20,7 @@
"label.average": "Сярэдняе",
"label.back": "Назад",
"label.before": "Да",
"label.behavior": "Паводзіны",
"label.boards": "Дошкі",
"label.bounce-rate": "Паказчык адмоваў",
"label.breakdown": "Разбіўка",

View file

@ -20,6 +20,7 @@
"label.average": "Средно",
"label.back": "Назад",
"label.before": "Преди",
"label.behavior": "Поведение",
"label.boards": "Дъски",
"label.bounce-rate": "Kоефициент на отказ",
"label.breakdown": "Разбивка",

View file

@ -20,6 +20,7 @@
"label.average": "গড়",
"label.back": "পেছনে",
"label.before": "পূর্বে",
"label.behavior": "আচরণ",
"label.boards": "বোর্ডসমূহ",
"label.bounce-rate": "উপরে উঠার হার",
"label.breakdown": "ভাঙ্গন",

View file

@ -20,6 +20,7 @@
"label.average": "Prosjek",
"label.back": "Nazad",
"label.before": "Prije",
"label.behavior": "Ponašanje",
"label.boards": "Ploče",
"label.bounce-rate": "Stopa napuštanja",
"label.breakdown": "Pregled po kategorijama",

View file

@ -20,6 +20,7 @@
"label.average": "Mitjana",
"label.back": "Enrere",
"label.before": "Abans",
"label.behavior": "Comportament",
"label.boards": "Taulers",
"label.bounce-rate": "Percentatge de rebot",
"label.breakdown": "Desglossament",

View file

@ -20,6 +20,7 @@
"label.average": "Průměr",
"label.back": "Zpět",
"label.before": "Před",
"label.behavior": "Chování",
"label.boards": "Nástěnky",
"label.bounce-rate": "Okamžité opuštění",
"label.breakdown": "Rozpis",

View file

@ -20,6 +20,7 @@
"label.average": "Gennemsnit",
"label.back": "Tilbage",
"label.before": "Før",
"label.behavior": "Adfærd",
"label.boards": "Tavler",
"label.bounce-rate": "Afvisningsprocent",
"label.breakdown": "Opdeling",

View file

@ -20,6 +20,7 @@
"label.average": "Durchschnitt",
"label.back": "Zrugg",
"label.before": "Vor",
"label.behavior": "Verhalte",
"label.boards": "Boards",
"label.bounce-rate": "Absprungsrate",
"label.breakdown": "Uufschlüsselig",

View file

@ -20,6 +20,7 @@
"label.average": "Durchschnitt",
"label.back": "Zurück",
"label.before": "Vor",
"label.behavior": "Verhalten",
"label.boards": "Boards",
"label.bounce-rate": "Absprungrate",
"label.breakdown": "Aufschlüsselung",

View file

@ -23,6 +23,7 @@
"label.boards": "Boards",
"label.bounce-rate": "Ποσοστό αναπήδησης",
"label.breakdown": "Breakdown",
"label.behavior": "Συμπεριφορά",
"label.browser": "Browser",
"label.browsers": "Προγράμματα περιήγησης",
"label.campaigns": "Campaigns",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "Back",
"label.before": "Before",
"label.behavior": "Behavior",
"label.boards": "Boards",
"label.bounce-rate": "Bounce rate",
"label.breakdown": "Breakdown",

View file

@ -289,6 +289,7 @@
"label.websites": "Websites",
"label.window": "Window",
"label.yesterday": "Yesterday",
"label.behavior": "Behavior",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.bad-request": "Bad request",

View file

@ -290,6 +290,7 @@
"label.websites": "Sitios web",
"label.window": "Ventana",
"label.yesterday": "Ayer",
"label.behavior": "Comportamiento",
"message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.",
"message.active-users": "{x} {x, plural, one {activo} other {activos}}",
"message.bad-request": "Bad request",

View file

@ -20,6 +20,7 @@
"label.average": "میانگین",
"label.back": "بازگشت",
"label.before": "قبل از",
"label.behavior": "رفتار",
"label.boards": "بردها",
"label.bounce-rate": "نرخ ریزش",
"label.breakdown": "تفکیک",

View file

@ -289,6 +289,7 @@
"label.websites": "Verkkosivut",
"label.window": "Window",
"label.yesterday": "Yesterday",
"label.behavior": "Behavior",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
"message.bad-request": "Bad request",

View file

@ -20,6 +20,7 @@
"label.average": "Miðal",
"label.back": "Aftur",
"label.before": "Áðrenn",
"label.behavior": "Atferð",
"label.boards": "Borð",
"label.bounce-rate": "Bounce prosenttal",
"label.breakdown": "Sundurgreining",

View file

@ -289,6 +289,9 @@
"label.websites": "Sites",
"label.window": "Fenêtre",
"label.yesterday": "Hier",
"label.behavior": "Comportement",
"label.traffic": "Trafic",
"label.segments": "Segments",
"message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
"message.bad-request": "Bad request",
@ -315,13 +318,13 @@
"message.no-teams": "Vous n'avez pas créé d'équipe.",
"message.no-users": "Aucun utilisateur.",
"message.no-websites-configured": "Vous n'avez pas configuré de site.",
"message.not-found": "Not found",
"message.nothing-selected": "Nothing selected.",
"message.not-found": "Non trouvé!",
"message.nothing-selected": "Rien n'est sélectionné.",
"message.page-not-found": "Page non trouvée.",
"message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.",
"message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
"message.saved": "Enregistré.",
"message.sever-error": "Server error",
"message.sever-error": "Erreur serveur",
"message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :",
"message.team-already-member": "Vous êtes déjà membre de cette équipe.",
"message.team-not-found": "Équipe non trouvée.",
@ -331,7 +334,7 @@
"message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.",
"message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.",
"message.triggered-event": "Évènement déclenché",
"message.unauthorized": "Unauthorized",
"message.unauthorized": "Non authorisé!",
"message.user-deleted": "Utilisateur supprimé.",
"message.viewed-page": "Page vue",
"message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}"

View file

@ -289,6 +289,7 @@
"label.websites": "Sitios web",
"label.window": "Ventá",
"label.yesterday": "Onte",
"label.behavior": "Comportamento",
"message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.",
"message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}",
"message.bad-request": "Bad request",

View file

@ -278,6 +278,7 @@
"label.value": "Value",
"label.view": "View",
"label.view-details": "פרטים נוספים",
"label.behavior": "התנהגות",
"label.view-only": "View only",
"label.views": "צפיות",
"label.views-per-visit": "Views per visit",

View file

@ -20,6 +20,7 @@
"label.average": "औसत",
"label.back": "पीछे",
"label.before": "पहले",
"label.behavior": "व्यवहार",
"label.boards": "बोर्ड्स",
"label.bounce-rate": "उछाल दर",
"label.breakdown": "विभाजन",

View file

@ -20,6 +20,7 @@
"label.average": "Prosjek",
"label.back": "Natrag ",
"label.before": "Prije",
"label.behavior": "Ponašanje",
"label.boards": "Ploče",
"label.bounce-rate": "Stopa napuštanja",
"label.breakdown": "Raspad",

View file

@ -20,6 +20,7 @@
"label.average": "Átlag",
"label.back": "Vissza",
"label.before": "Előtt",
"label.behavior": "Viselkedés",
"label.boards": "Táblák",
"label.bounce-rate": "Visszafordulási arány",
"label.breakdown": "Bontás",

View file

@ -20,6 +20,7 @@
"label.average": "Rata-rata",
"label.back": "Kembali",
"label.before": "Sebelum",
"label.behavior": "Perilaku",
"label.boards": "Papan",
"label.bounce-rate": "Rasio pentalan",
"label.breakdown": "Rincian",

View file

@ -20,6 +20,7 @@
"label.average": "Media",
"label.back": "Indietro",
"label.before": "Prima",
"label.behavior": "Comportamento",
"label.boards": "Bacheche",
"label.bounce-rate": "Frequenza di rimbalzo",
"label.breakdown": "Dettaglio",

View file

@ -20,6 +20,7 @@
"label.average": "平均",
"label.back": "戻る",
"label.before": "直前",
"label.behavior": "行動",
"label.boards": "ボード",
"label.bounce-rate": "直帰率",
"label.breakdown": "故障",

View file

@ -20,6 +20,7 @@
"label.average": "ជាមធ្យម",
"label.back": "ថយក្រោយ",
"label.before": "មុន",
"label.behavior": "អាកប្បកិរិយា",
"label.boards": "ក្តារ",
"label.bounce-rate": "ចំនួនវិលត្រឡប់",
"label.breakdown": "បំបែកលម្អិត",

View file

@ -20,6 +20,7 @@
"label.average": "평균",
"label.back": "뒤로",
"label.before": "이전",
"label.behavior": "행동",
"label.boards": "보드",
"label.bounce-rate": "이탈률",
"label.breakdown": "세부 사항",

View file

@ -20,6 +20,7 @@
"label.average": "Vidurkis",
"label.back": "Atgal",
"label.before": "Prieš",
"label.behavior": "Elgsena",
"label.boards": "Lentos",
"label.bounce-rate": "Atmetimo rodiklis",
"label.breakdown": "Išskaidymas",

View file

@ -20,6 +20,7 @@
"label.average": "Дундаж",
"label.back": "Буцах",
"label.before": "Өмнө",
"label.behavior": "Зан төлөв",
"label.boards": "Самбарууд",
"label.bounce-rate": "Нэг хуудас үзээд гарсан",
"label.breakdown": "Задаргаа",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "Kembali",
"label.before": "Before",
"label.behavior": "Behavior",
"label.boards": "Boards",
"label.bounce-rate": "Kadar lantunan",
"label.breakdown": "Breakdown",

View file

@ -20,6 +20,7 @@
"label.average": "ပျမ်းမျှ",
"label.back": "နောက်သို့",
"label.before": "မတိုင်မီ",
"label.behavior": "အပြုအမူ",
"label.boards": "Boards",
"label.bounce-rate": "Bounce နှုန်း",
"label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု",

View file

@ -20,6 +20,7 @@
"label.average": "Gjennomsnnitt",
"label.back": "Tilbake",
"label.before": "Før",
"label.behavior": "Atferd",
"label.boards": "Tavler",
"label.bounce-rate": "Avvisningsfrekvens",
"label.breakdown": "Nedbrytning",

View file

@ -20,6 +20,7 @@
"label.average": "Gemiddelde",
"label.back": "Terug",
"label.before": "Voor",
"label.behavior": "Gedrag",
"label.boards": "Borden",
"label.bounce-rate": "Bouncepercentage",
"label.breakdown": "Opsplitsen",

View file

@ -20,6 +20,7 @@
"label.average": "Średnia",
"label.back": "Powrót",
"label.before": "Przed",
"label.behavior": "Zachowanie",
"label.boards": "Tablice",
"label.bounce-rate": "Współczynnik odrzuceń",
"label.breakdown": "Rozbicie",

View file

@ -20,6 +20,7 @@
"label.average": "Média",
"label.back": "Voltar",
"label.before": "Antes",
"label.behavior": "Comportamento",
"label.boards": "Quadros",
"label.bounce-rate": "Taxa de rejeição",
"label.breakdown": "Detalhamento",

View file

@ -20,6 +20,7 @@
"label.average": "Média",
"label.back": "Voltar",
"label.before": "Antes",
"label.behavior": "Comportamento",
"label.boards": "Quadros",
"label.bounce-rate": "Taxa de rejeição",
"label.breakdown": "Detalhamento",

View file

@ -20,6 +20,7 @@
"label.average": "Mediu",
"label.back": "Înapoi",
"label.before": "Înainte",
"label.behavior": "Comportament",
"label.boards": "Panouri",
"label.bounce-rate": "Rata de respingere",
"label.breakdown": "Detaliat",

View file

@ -20,6 +20,7 @@
"label.average": "Средний",
"label.back": "Назад",
"label.before": "До",
"label.behavior": "Поведение",
"label.boards": "Доски",
"label.bounce-rate": "Отказы",
"label.breakdown": "Авария",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "ආපසු",
"label.before": "Before",
"label.behavior": "අචරණය",
"label.boards": "Boards",
"label.bounce-rate": "Bounce rate",
"label.breakdown": "Breakdown",

View file

@ -20,6 +20,7 @@
"label.average": "Priemer",
"label.back": "Späť",
"label.before": "Pred",
"label.behavior": "Správanie",
"label.boards": "Tabule",
"label.bounce-rate": "Okamžité opustenie",
"label.breakdown": "Rozpis",

View file

@ -20,6 +20,7 @@
"label.average": "Povprečno",
"label.back": "Nazaj",
"label.before": "Pred",
"label.behavior": "Obnašanje",
"label.boards": "Table",
"label.bounce-rate": "Odbojna stopnja",
"label.breakdown": "Razčlenitev",

View file

@ -20,6 +20,7 @@
"label.average": "Genomsnitt",
"label.back": "Tillbaka",
"label.before": "Före",
"label.behavior": "Beteende",
"label.boards": "Anslagstavlor",
"label.bounce-rate": "Avvisningsfrekvens",
"label.breakdown": "Analys",

View file

@ -21,6 +21,7 @@
"label.back": "பின்னால்",
"label.before": "Before",
"label.boards": "Boards",
"label.behavior": "நடத்தை",
"label.bounce-rate": "துள்ளல் விகிதம்",
"label.breakdown": "Breakdown",
"label.browser": "Browser",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "ย้อนกลับ",
"label.before": "Before",
"label.behavior": "พฤติกรรม",
"label.boards": "Boards",
"label.bounce-rate": "อัตราตีกลับ",
"label.breakdown": "Breakdown",

View file

@ -20,6 +20,7 @@
"label.average": "Ortalama",
"label.back": "Geri",
"label.before": "Önce",
"label.behavior": "Davranış",
"label.boards": "Panolar",
"label.bounce-rate": "Tek sayfa ziyaret oranı",
"label.breakdown": "Dağılım",

View file

@ -20,6 +20,7 @@
"label.average": "Середній",
"label.back": "Назад",
"label.before": "До",
"label.behavior": "Поведінка",
"label.boards": "Дошки",
"label.bounce-rate": "Показник відмов",
"label.breakdown": "Розподіл",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "پیچھے",
"label.before": "Before",
"label.behavior": "رویے",
"label.boards": "Boards",
"label.bounce-rate": "اچھال کی شرح",
"label.breakdown": "Breakdown",

View file

@ -15,6 +15,7 @@
"label.average": "Oʻrtacha",
"label.back": "Orqaga",
"label.before": "Oldin",
"label.behavior": "Xulq-atvor",
"label.bounce-rate": "Chiqib ketish darajasi",
"label.breakdown": "Tahlil",
"label.browser": "Brauzer",

View file

@ -15,6 +15,7 @@
"label.average": "Trung bình",
"label.back": "Quay lại",
"label.before": "Trước đó",
"label.behavior": "Hành vi",
"label.bounce-rate": "Tỷ lệ thoát trang",
"label.breakdown": "Phân tích chi tiết",
"label.browser": "Trình duyệt",

View file

@ -20,6 +20,7 @@
"label.average": "平均",
"label.back": "返回",
"label.before": "之前",
"label.behavior": "行为",
"label.boards": "看板",
"label.bounce-rate": "跳出率",
"label.breakdown": "故障",

View file

@ -20,6 +20,7 @@
"label.average": "平均",
"label.back": "返回",
"label.before": "之前",
"label.behavior": "行為",
"label.boards": "看板",
"label.bounce-rate": "跳出率",
"label.breakdown": "細項分析",

View file

@ -114,9 +114,9 @@ export async function getClientInfo(request: Request, payload: Record<string, an
const country = safeDecodeURIComponent(location?.country);
const region = safeDecodeURIComponent(location?.region);
const city = safeDecodeURIComponent(location?.city);
const browser = browserName(userAgent);
const os = detectOS(userAgent) as string;
const device = getDevice(userAgent, payload?.screen);
const browser = payload?.browser ?? browserName(userAgent);
const os = payload?.os ?? (detectOS(userAgent) as string);
const device = payload?.device ?? getDevice(userAgent, payload?.screen);
return { userAgent, browser, os, ip, country, region, city, device };
}