├── tinybird
├── .env.local
├── .gitignore
├── datasources
│ ├── analytics_pages_mv.datasource
│ ├── analytics_sources_mv.datasource
│ ├── tenant_domains_mv.datasource
│ ├── tenant_actions_mv.datasource
│ ├── analytics_sessions_mv.datasource
│ └── analytics_events.datasource
├── endpoints
│ ├── current_visitors.pipe
│ ├── domain.pipe
│ ├── domains.pipe
│ ├── actions.pipe
│ ├── trend.pipe
│ ├── analytics_hits.pipe
│ ├── top_devices.pipe
│ ├── top_browsers.pipe
│ ├── top_locations.pipe
│ ├── top_pages.pipe
│ └── top_sources.pipe
├── materializations
│ ├── tenant_domains.pipe
│ ├── analytics_pages.pipe
│ ├── analytics_sources.pipe
│ ├── analytics_sessions.pipe
│ └── tenant_actions.pipe
├── copies
│ └── random_data_generator.pipe
├── web_vitals
│ └── endpoints
│ │ ├── web_vitals_timeseries.pipe
│ │ └── web_vitals_current.pipe
├── README.md
├── fixtures
│ ├── analytics_events.sql.backup
│ └── analytics_events.sql
└── mock
│ └── schema.json
├── middleware
├── .vercelignore
├── .gitignore
├── .npmignore
├── src
│ └── index.html
├── package.json
└── api
│ └── tracking.js
├── .gitignore
├── dashboard-template.png
├── assets
└── img
│ ├── data_flow.png
│ ├── repo-banner.png
│ ├── banner_snippet.png
│ ├── events-incoming.png
│ └── banner_dashboard.png
├── dashboard
├── public
│ ├── banner.png
│ ├── chart.png
│ ├── favicon.ico
│ ├── fallback-logo.png
│ ├── fonts
│ │ └── Inter-roman-latin.var.woff2
│ ├── icon.svg
│ └── manifest.json
├── postcss.config.js
├── lib
│ ├── constants
│ │ ├── index.ts
│ │ ├── devices.ts
│ │ ├── browsers.ts
│ │ └── metrics.ts
│ ├── config.ts
│ ├── types
│ │ └── api.ts
│ ├── hooks
│ │ ├── use-domains.ts
│ │ ├── use-auth.ts
│ │ ├── use-current-visitors.ts
│ │ ├── use-endpoint.ts
│ │ ├── use-domain.ts
│ │ └── use-time-range.ts
│ ├── types.ts
│ ├── utils.ts
│ └── api.ts
├── .prettierrc
├── components
│ ├── ui
│ │ ├── Link.module.css
│ │ ├── SqlChart.module.css
│ │ ├── Input.tsx
│ │ ├── Link.tsx
│ │ ├── Tooltip.module.css
│ │ ├── Textarea.tsx
│ │ ├── Skeleton.tsx
│ │ ├── Textarea.module.css
│ │ ├── Loader.module.css
│ │ ├── Badge.module.css
│ │ ├── Stack.tsx
│ │ ├── TimeRangeSelect.tsx
│ │ ├── Dialog.module.css
│ │ ├── Badge.tsx
│ │ ├── Tooltip.tsx
│ │ ├── Loader.tsx
│ │ ├── Tabs.tsx
│ │ ├── Button.tsx
│ │ ├── DomainSelect.tsx
│ │ ├── Tabs.module.css
│ │ ├── Dialog.tsx
│ │ ├── Input.module.css
│ │ ├── Select.module.css
│ │ ├── Text.module.css
│ │ ├── Chart.module.css
│ │ ├── Button.module.css
│ │ └── Text.tsx
│ ├── ai-chat
│ │ ├── index.ts
│ │ ├── AIChatStandalone.tsx
│ │ ├── example-usage.tsx
│ │ ├── AIChatProvider.tsx
│ │ ├── AIChatToolCall.tsx
│ │ ├── README.md
│ │ ├── AIChatContainer.tsx
│ │ ├── AIChatForm.tsx
│ │ └── InsightCards.tsx
│ ├── table
│ │ ├── TableCells.module.css
│ │ └── TableCells.tsx
│ ├── Provider.tsx
│ └── Header.tsx
├── mocks
│ ├── server.ts
│ └── handlers.ts
├── .vscode
│ └── settings.json
├── assets
│ └── fonts
│ │ └── iawritermonos-regular.woff2
├── .env.example
├── next.config.js
├── app
│ ├── widgets
│ │ ├── index.tsx
│ │ ├── visitors.tsx
│ │ ├── pageviews.tsx
│ │ ├── top-browsers.tsx
│ │ ├── top-devices.tsx
│ │ ├── top-pages.tsx
│ │ ├── top-sources.tsx
│ │ └── top-locations.tsx
│ ├── DashboardTabs.tsx
│ └── layout.tsx
├── .eslintrc.js
├── .gitignore
├── styles
│ └── theme
│ │ └── index.js
├── tailwind.config.js
├── tsconfig.json
├── package.json
└── README.md
├── CONTRIBUTING.md
├── .github
└── workflows
│ ├── release-workflow.yml
│ ├── tinybird-ci.yml
│ └── ci-workflow.yml
├── LICENSE
├── TEMPLATE.md
├── CHANGELOG.md
└── CODE_OF_CONDUCT.md
/tinybird/.env.local:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tinybird/.gitignore:
--------------------------------------------------------------------------------
1 | .tinyb
2 | .terraform
3 |
--------------------------------------------------------------------------------
/middleware/.vercelignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .parcel-cache
4 |
--------------------------------------------------------------------------------
/middleware/.gitignore:
--------------------------------------------------------------------------------
1 | .vercel
2 | node_modules
3 | dist
4 | .parcel-cache
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .venv
3 | .tinyb
4 | .vercel
5 | node_modules
6 | dist
7 | .e
8 | .env
9 | tmp/
--------------------------------------------------------------------------------
/dashboard-template.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard-template.png
--------------------------------------------------------------------------------
/assets/img/data_flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/assets/img/data_flow.png
--------------------------------------------------------------------------------
/assets/img/repo-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/assets/img/repo-banner.png
--------------------------------------------------------------------------------
/dashboard/public/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard/public/banner.png
--------------------------------------------------------------------------------
/dashboard/public/chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard/public/chart.png
--------------------------------------------------------------------------------
/dashboard/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard/public/favicon.ico
--------------------------------------------------------------------------------
/assets/img/banner_snippet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/assets/img/banner_snippet.png
--------------------------------------------------------------------------------
/assets/img/events-incoming.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/assets/img/events-incoming.png
--------------------------------------------------------------------------------
/dashboard/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/assets/img/banner_dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/assets/img/banner_dashboard.png
--------------------------------------------------------------------------------
/dashboard/public/fallback-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard/public/fallback-logo.png
--------------------------------------------------------------------------------
/dashboard/lib/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './metrics'
2 | export * from './countries'
3 | export * from './browsers'
4 | export * from './devices'
--------------------------------------------------------------------------------
/dashboard/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "useTabs": false,
6 | "arrowParens": "avoid"
7 | }
8 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Link.module.css:
--------------------------------------------------------------------------------
1 | .link {
2 | color: var(--alternative-color);
3 |
4 | &:hover {
5 | text-decoration: underline;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/dashboard/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node'
2 | import { handlers } from './handlers'
3 |
4 | export const server = setupServer(...handlers)
5 |
--------------------------------------------------------------------------------
/dashboard/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | {
4 | "directory": "./",
5 | "changeProcessCWD": true
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/dashboard/assets/fonts/iawritermonos-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard/assets/fonts/iawritermonos-regular.woff2
--------------------------------------------------------------------------------
/dashboard/public/fonts/Inter-roman-latin.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinybirdco/web-analytics-starter-kit/HEAD/dashboard/public/fonts/Inter-roman-latin.var.woff2
--------------------------------------------------------------------------------
/middleware/.npmignore:
--------------------------------------------------------------------------------
1 | #.npmignore
2 | src
3 | test
4 | CHANGELOG.md
5 | .parcel-cache
6 | .vercel
7 | node_modules
8 | .eslintrc
9 | .babelrc
10 | examples
11 | .vercelignore
12 |
--------------------------------------------------------------------------------
/dashboard/lib/constants/devices.ts:
--------------------------------------------------------------------------------
1 | const devices = {
2 | desktop: 'Desktop',
3 | 'mobile-android': 'Android',
4 | 'mobile-ios': 'iOS',
5 | bot: 'Bots',
6 | }
7 |
8 | export default devices
9 |
--------------------------------------------------------------------------------
/dashboard/lib/constants/browsers.ts:
--------------------------------------------------------------------------------
1 | const browsers = {
2 | chrome: 'Chrome',
3 | safari: 'Safari',
4 | opera: 'Opera',
5 | firefox: 'Firefox',
6 | ie: 'IE',
7 | }
8 |
9 | export default browsers
10 |
--------------------------------------------------------------------------------
/dashboard/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_ASK_TINYBIRD_ENDPOINT="https://ask-tb.tinybird.live/api/chat"
2 |
3 | NEXT_PUBLIC_TINYBIRD_DASHBOARD_URL=http://localhost:3000
4 | NEXT_PUBLIC_TINYBIRD_TRACKER_TOKEN=
5 | NEXT_PUBLIC_TINYBIRD_AUTH_TOKEN=
6 | NEXT_PUBLIC_TINYBIRD_HOST=
--------------------------------------------------------------------------------
/dashboard/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | i18n: {
6 | locales: ['en'],
7 | defaultLocale: 'en',
8 | },
9 | }
10 |
11 | module.exports = nextConfig
12 |
--------------------------------------------------------------------------------
/dashboard/components/ui/SqlChart.module.css:
--------------------------------------------------------------------------------
1 | .sqlChartCard {
2 | min-width: 240px;
3 | flex: 1;
4 | border-radius: 4px;
5 | transition: border-color 0.2s ease-in-out;
6 | }
7 |
8 | .sqlChartCard.clickable:hover {
9 | border-color: var(--border-03-color);
10 | }
--------------------------------------------------------------------------------
/middleware/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | If you are not redirected, click here .
8 |
9 |
10 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Input.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as React from 'react'
3 | import styles from './Input.module.css'
4 | import { cn } from '@/lib/utils'
5 |
6 | export function Input({ className, ...props }: React.ComponentProps<'input'>) {
7 | return
8 | }
9 |
--------------------------------------------------------------------------------
/dashboard/app/widgets/index.tsx:
--------------------------------------------------------------------------------
1 | export { Visitors } from './visitors'
2 | export { Pageviews } from './pageviews'
3 | export { TopPages } from './top-pages'
4 | export { TopLocations } from './top-locations'
5 | export { TopSources } from './top-sources'
6 | export { TopDevices } from './top-devices'
7 | export { TopBrowsers } from './top-browsers'
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/index.ts:
--------------------------------------------------------------------------------
1 | export { AIChatProvider, useAIChat } from './AIChatProvider'
2 | export { AIChatForm } from './AIChatForm'
3 | export { AIChatMessage } from './AIChatMessage'
4 | export { AIChatToolCall } from './AIChatToolCall'
5 | export { AIChatContainer } from './AIChatContainer'
6 | export { AIChatStandalone } from './AIChatStandalone'
--------------------------------------------------------------------------------
/dashboard/lib/config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | dashboardURL: process.env.NEXT_PUBLIC_TINYBIRD_DASHBOARD_URL as string,
3 | trackerToken: process.env.NEXT_PUBLIC_TINYBIRD_TRACKER_TOKEN as string,
4 | authToken: process.env.NEXT_PUBLIC_TINYBIRD_AUTH_TOKEN as string,
5 | host: process.env.NEXT_PUBLIC_TINYBIRD_HOST as string,
6 | tenantId: process.env.TENANT_ID as string,
7 | } as const
8 |
9 | export default config
10 |
--------------------------------------------------------------------------------
/dashboard/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('eslint').Linter.Config}
3 | */
4 | module.exports = {
5 | extends: ['next/core-web-vitals', 'prettier'],
6 | plugins: ['prettier'],
7 | ignorePatterns: ['node_modules', 'dist'],
8 | parserOptions: {
9 | babelOptions: {
10 | presets: [require.resolve('next/babel')],
11 | },
12 | },
13 | rules: {
14 | '@next/next/no-img-element': 'off',
15 | },
16 | }
17 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Link.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import NextLink from 'next/link'
3 | import type { ComponentProps } from 'react'
4 | import styles from './Link.module.css'
5 |
6 | type LinkProps = ComponentProps & {
7 | className?: string
8 | }
9 |
10 | export function Link({ className, ...props }: LinkProps) {
11 | return
12 | }
13 |
--------------------------------------------------------------------------------
/dashboard/public/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Tooltip.module.css:
--------------------------------------------------------------------------------
1 | .content {
2 | z-index: 50;
3 | overflow: hidden;
4 | border-radius: 4px;
5 | background-color: var(--background-dark-color);
6 | box-shadow: 0 3px 7px rgb(0 0 0 / 13%), 0 0.6px 2px rgb(0 0 0 / 10%);
7 | padding: 4px 8px;
8 | font: var(--font-caption);
9 | color: var(--text-inverse-color);
10 | }
11 |
12 | .content.light {
13 | background-color: var(--background-color);
14 | color: var(--text-color);
15 | }
--------------------------------------------------------------------------------
/tinybird/datasources/analytics_pages_mv.datasource:
--------------------------------------------------------------------------------
1 | SCHEMA >
2 | `date` Date,
3 | `tenant_id` String,
4 | `domain` String,
5 | `device` String,
6 | `browser` String,
7 | `location` String,
8 | `pathname` String,
9 | `visits` AggregateFunction(uniq, String),
10 | `hits` AggregateFunction(count)
11 |
12 | ENGINE AggregatingMergeTree
13 | ENGINE_PARTITION_KEY toYYYYMM(date)
14 | ENGINE_SORTING_KEY tenant_id, domain, date, device, browser, location, pathname
--------------------------------------------------------------------------------
/dashboard/components/ui/Textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 | import styles from './Textarea.module.css'
5 |
6 | const Textarea = React.forwardRef<
7 | HTMLTextAreaElement,
8 | React.ComponentPropsWithoutRef<'textarea'>
9 | >(({ className, ...props }, ref) => {
10 | return
11 | })
12 |
13 | Textarea.displayName = 'Textarea'
14 |
15 | export { Textarea }
16 |
--------------------------------------------------------------------------------
/tinybird/datasources/analytics_sources_mv.datasource:
--------------------------------------------------------------------------------
1 | SCHEMA >
2 | `date` Date,
3 | `tenant_id` String,
4 | `domain` String,
5 | `device` String,
6 | `browser` String,
7 | `location` String,
8 | `referrer` String,
9 | `visits` AggregateFunction(uniq, String),
10 | `hits` AggregateFunction(count)
11 |
12 | ENGINE AggregatingMergeTree
13 | ENGINE_PARTITION_KEY toYYYYMM(date)
14 | ENGINE_SORTING_KEY tenant_id, domain, date, device, browser, location, referrer
--------------------------------------------------------------------------------
/tinybird/datasources/tenant_domains_mv.datasource:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Materialized datasource for tracking domains per tenant
3 |
4 | SCHEMA >
5 | `tenant_id` String,
6 | `domain` String,
7 | `first_seen` SimpleAggregateFunction(min, DateTime),
8 | `last_seen` SimpleAggregateFunction(max, DateTime),
9 | `total_hits` AggregateFunction(count)
10 |
11 | ENGINE "AggregatingMergeTree"
12 | ENGINE_PARTITION_KEY "toYYYYMM(last_seen)"
13 | ENGINE_SORTING_KEY "tenant_id, domain"
--------------------------------------------------------------------------------
/dashboard/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Dashboard",
3 | "short_name": "Dashboard",
4 | "display": "standalone",
5 | "orientation": "portrait",
6 | "purpose": "any maskable",
7 | "theme_color": "#0066FF",
8 | "background_color": "#0066FF",
9 | "start_url": "/",
10 | "icons": [
11 | {
12 | "src": "/icon.png",
13 | "sizes": "192x192",
14 | "type": "image/png"
15 | },
16 | {
17 | "src": "/icon.png",
18 | "sizes": "512x512",
19 | "type": "image/png"
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/tinybird/endpoints/current_visitors.pipe:
--------------------------------------------------------------------------------
1 | TOKEN "dashboard" READ
2 |
3 | NODE get_current_visitors
4 | SQL >
5 | %
6 | SELECT uniq(session_id) AS visits
7 | FROM analytics_hits
8 | WHERE timestamp >= (now() - interval 5 minute)
9 | {% if defined(tenant_id) %}
10 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
11 | {% end %}
12 | {% if defined(domain) %}
13 | AND domain = {{ String(domain, description="Filter by domain") }}
14 | {% end %}
15 |
16 | TYPE endpoint
--------------------------------------------------------------------------------
/dashboard/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/tinybird/datasources/tenant_actions_mv.datasource:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Materialized datasource for storing distinct actions by tenant and domain with last payload sample
3 |
4 | SCHEMA >
5 | `tenant_id` String,
6 | `domain` String,
7 | `action` String,
8 | `last_payload` SimpleAggregateFunction(any, String),
9 | `last_seen` SimpleAggregateFunction(max, DateTime),
10 | `total_occurrences` AggregateFunction(count)
11 |
12 | ENGINE "AggregatingMergeTree"
13 | ENGINE_PARTITION_KEY "toYYYYMM(last_seen)"
14 | ENGINE_SORTING_KEY "tenant_id, domain, action"
--------------------------------------------------------------------------------
/tinybird/materializations/tenant_domains.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Materializes domain data from analytics hits
3 |
4 | NODE tenant_domains_node
5 | DESCRIPTION >
6 | Aggregate domains per tenant with first/last seen timestamps
7 |
8 | SQL >
9 | SELECT
10 | tenant_id,
11 | domain,
12 | minSimpleState(timestamp) AS first_seen,
13 | maxSimpleState(timestamp) AS last_seen,
14 | countState() AS total_hits
15 | FROM analytics_hits
16 | GROUP BY tenant_id, domain
17 |
18 | TYPE MATERIALIZED
19 | DATASOURCE tenant_domains_mv
--------------------------------------------------------------------------------
/tinybird/endpoints/domain.pipe:
--------------------------------------------------------------------------------
1 | TOKEN "dashboard" READ
2 |
3 | NODE get_current_domain
4 | SQL >
5 |
6 | WITH (
7 | SELECT nullif(domainWithoutWWW(href),'') as domain
8 | FROM analytics_hits
9 | WHERE timestamp >= now() - interval 1 hour
10 | GROUP BY domain
11 | ORDER BY count(1) DESC
12 | LIMIT 1
13 | ) AS top_domain,
14 | (
15 | SELECT domainWithoutWWW(href)
16 | FROM analytics_hits
17 | WHERE href NOT LIKE '%localhost%'
18 | LIMIT 1
19 | ) AS some_domain
20 | SELECT coalesce(top_domain, some_domain) AS domain
21 |
22 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/materializations/analytics_pages.pipe:
--------------------------------------------------------------------------------
1 | NODE analytics_pages_1
2 | DESCRIPTION >
3 | Aggregate by pathname and calculate session and hits
4 |
5 | SQL >
6 | SELECT
7 | toDate(timestamp) AS date,
8 | tenant_id,
9 | domain,
10 | device,
11 | browser,
12 | location,
13 | pathname,
14 | uniqState(session_id) AS visits,
15 | countState() AS hits
16 | FROM analytics_hits
17 | GROUP BY date, tenant_id, domain, device, browser, location, pathname
18 |
19 | TYPE MATERIALIZED
20 | DATASOURCE analytics_pages_mv
--------------------------------------------------------------------------------
/dashboard/components/ui/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export function Skeleton({
4 | width = "100%",
5 | height = "100%",
6 | style,
7 | ...props
8 | }: ComponentProps<"div"> & {
9 | width?: number | string;
10 | height?: number | string;
11 | }) {
12 | return (
13 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Textarea.module.css:
--------------------------------------------------------------------------------
1 | .textarea {
2 | display: flex;
3 | min-height: 60px;
4 | width: 100%;
5 | border-radius: 6px;
6 | border: 1px solid var(--border-01-color);
7 | background-color: transparent;
8 | padding: 8px 12px;
9 | font: var(--font-body);
10 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
11 | }
12 |
13 | .textarea::placeholder {
14 | color: var(--text-01-color);
15 | }
16 |
17 | .textarea:focus-visible {
18 | outline: none;
19 | ring: 1px solid var(--border-03-color);
20 | }
21 |
22 | .textarea:disabled {
23 | cursor: not-allowed;
24 | opacity: 0.5;
25 | }
26 |
--------------------------------------------------------------------------------
/tinybird/datasources/analytics_sessions_mv.datasource:
--------------------------------------------------------------------------------
1 | SCHEMA >
2 | `date` Date,
3 | `session_id` String,
4 | `tenant_id` String,
5 | `domain` String,
6 | `device` SimpleAggregateFunction(any, String),
7 | `browser` SimpleAggregateFunction(any, String),
8 | `location` SimpleAggregateFunction(any, String),
9 | `first_hit` SimpleAggregateFunction(min, DateTime),
10 | `latest_hit` SimpleAggregateFunction(max, DateTime),
11 | `hits` AggregateFunction(count)
12 |
13 | ENGINE AggregatingMergeTree
14 | ENGINE_PARTITION_KEY toYYYYMM(date)
15 | ENGINE_SORTING_KEY tenant_id, domain, date, session_id
--------------------------------------------------------------------------------
/dashboard/styles/theme/index.js:
--------------------------------------------------------------------------------
1 | const colors = {
2 | current: 'currentColor',
3 | primary: '#27F795',
4 | primaryDark: '#27F795',
5 | primaryLight: '#EBFEF5',
6 | secondary: '#25283D',
7 | secondaryLight: '#A5A7B4',
8 | success: '#1FCC83',
9 | warning: '#FFFDE9',
10 | error: '#F76363',
11 | body: '#f6f7f9',
12 | 'neutral-01': '#fff',
13 | 'neutral-04': '#fdfdfe',
14 | 'neutral-08': '#f4f4f7',
15 | 'neutral-12': '#e8e9ed',
16 | 'neutral-24': '#CBCCD1',
17 | 'neutral-64': '#636679',
18 | }
19 |
20 | const typography = {
21 | fontFamily: 'Inter var',
22 | }
23 |
24 | module.exports = { colors, typography }
25 |
--------------------------------------------------------------------------------
/tinybird/datasources/analytics_events.datasource:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Analytics events landing data source
3 |
4 | TOKEN "tracker" APPEND
5 |
6 | SCHEMA >
7 | `timestamp` DateTime `json:$.timestamp`,
8 | `session_id` Nullable(String) `json:$.session_id`,
9 | `action` LowCardinality(String) `json:$.action`,
10 | `version` LowCardinality(String) `json:$.version`,
11 | `payload` String `json:$.payload`,
12 | `tenant_id` String `json:$.tenant_id` DEFAULT '',
13 | `domain` String `json:$.domain` DEFAULT ''
14 |
15 | ENGINE MergeTree
16 | ENGINE_PARTITION_KEY toYYYYMM(timestamp)
17 | ENGINE_SORTING_KEY tenant_id, domain, timestamp
18 |
--------------------------------------------------------------------------------
/tinybird/materializations/analytics_sources.pipe:
--------------------------------------------------------------------------------
1 | NODE analytics_sources_1
2 | DESCRIPTION >
3 | Aggregate by referral and calculate session and hits
4 |
5 | SQL >
6 | SELECT
7 | toDate(timestamp) AS date,
8 | tenant_id,
9 | domain,
10 | device,
11 | browser,
12 | location,
13 | referrer,
14 | uniqState(session_id) AS visits,
15 | countState() AS hits
16 | FROM analytics_hits
17 | WHERE domainWithoutWWW(referrer) != current_domain
18 | GROUP BY date, tenant_id, domain, device, browser, location, referrer
19 |
20 | TYPE MATERIALIZED
21 | DATASOURCE analytics_sources_mv
--------------------------------------------------------------------------------
/dashboard/lib/types/api.ts:
--------------------------------------------------------------------------------
1 | export type ClientResponse = {
2 | data: T
3 | meta?: Array<{
4 | name: string
5 | type: string
6 | }>
7 | rows?: number
8 | statistics?: {
9 | elapsed: number
10 | rows_read: number
11 | bytes_read: number
12 | }
13 | error?: string
14 | }
15 |
16 | export type PipeParams = Record
17 |
18 | export type QueryPipe = ClientResponse
19 |
20 | export type QuerySQL = ClientResponse
21 |
22 | export class QueryError extends Error {
23 | constructor(
24 | message: string,
25 | public status: number
26 | ) {
27 | super(message)
28 | this.name = 'QueryError'
29 | }
30 | }
--------------------------------------------------------------------------------
/tinybird/endpoints/domains.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Returns domains for a given tenant with first/last seen timestamps and total hits
3 |
4 | TOKEN "dashboard" READ
5 |
6 | NODE endpoint
7 | DESCRIPTION >
8 | Get domains for a specific tenant
9 |
10 | SQL >
11 | %
12 | SELECT
13 | domain,
14 | minSimpleState(first_seen) AS first_seen,
15 | maxSimpleState(last_seen) AS last_seen,
16 | countMerge(total_hits) AS total_hits
17 | FROM tenant_domains_mv
18 | WHERE tenant_id = {{ String(tenant_id, description="Tenant ID to filter domains for", default="") }}
19 | GROUP BY domain
20 | ORDER BY total_hits DESC, domain ASC
21 |
22 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/materializations/analytics_sessions.pipe:
--------------------------------------------------------------------------------
1 | NODE analytics_sessions_1
2 | DESCRIPTION >
3 | Aggregate by session_id and calculate session metrics
4 |
5 | SQL >
6 | SELECT
7 | toDate(timestamp) AS date,
8 | session_id,
9 | tenant_id,
10 | domain,
11 | anySimpleState(device) AS device,
12 | anySimpleState(browser) AS browser,
13 | anySimpleState(location) AS location,
14 | minSimpleState(timestamp) AS first_hit,
15 | maxSimpleState(timestamp) AS latest_hit,
16 | countState() AS hits
17 | FROM analytics_hits
18 | GROUP BY date, session_id, tenant_id, domain
19 |
20 | TYPE MATERIALIZED
21 | DATASOURCE analytics_sessions_mv
--------------------------------------------------------------------------------
/dashboard/app/DashboardTabs.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'
2 | import { Widgets, CoreVitals } from './widgets'
3 |
4 | const DashboardTabs = ({ domain }: { domain?: string }) => {
5 | return (
6 |
7 |
8 | Analytics
9 | Speed Insights
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
21 | export default DashboardTabs
--------------------------------------------------------------------------------
/dashboard/lib/hooks/use-domains.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 | import { queryPipe } from '../api'
3 |
4 | export type Domain = {
5 | domain: string
6 | first_seen: string
7 | last_seen: string
8 | total_hits: number
9 | }
10 |
11 | export function useDomains(tenant_id: string = '') {
12 | const fetcher = async () => {
13 | const params = tenant_id ? { tenant_id } : {}
14 | const { data } = await queryPipe('domains', params)
15 | return data
16 | }
17 |
18 | const { data, error, isLoading } = useSWR(['domains', tenant_id], fetcher, {
19 | revalidateOnFocus: false,
20 | revalidateOnReconnect: false,
21 | })
22 |
23 | return {
24 | domains: data,
25 | error,
26 | isLoading,
27 | }
28 | }
--------------------------------------------------------------------------------
/dashboard/lib/hooks/use-auth.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useSearchParams } from 'next/navigation'
4 | import { useAnalytics } from '../../components/Provider'
5 | import config from '../config'
6 |
7 | export default function useAuth() {
8 | const searchParams = useSearchParams()
9 |
10 | let token, host
11 | if (config.host && config.authToken) {
12 | token = config.authToken
13 | host = config.host
14 | } else {
15 | token = searchParams?.get('token') || undefined
16 | host = searchParams?.get('host') || undefined
17 | }
18 |
19 | const { error } = useAnalytics()
20 | const isTokenValid = !error || ![401, 403].includes(error.status ?? 0)
21 | const isAuthenticated = !!token && !!host
22 | return { isAuthenticated, token, host, isTokenValid }
23 | }
24 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How do I make a contribution?
2 |
3 | We are happy to receive pull requests contributions from the community. New features, improvements to the existing ones, minor fixes, quality code related tasks... anything else that raises the bar of the project.
4 |
5 | Branching strategy:
6 |
7 | - Create a new branch, using these prefixes: `feature/`, `doc/`, `bugfix/`, etc.
8 | - Set `main` as target.
9 |
10 | Every pull request must comply all of the following:
11 |
12 | - Motivation and description (with technical details).
13 | - Successful functional validation.
14 | - Successful build status.
15 | - No merge conflicts.
16 | - No dependencies conflicts.
17 | - Follow code style guide.
18 | - All tests passing.
19 | - Updated README and CHANGELOG if applies.
20 |
21 | If you have any doubt, please contact the maintainers.
22 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Loader.module.css:
--------------------------------------------------------------------------------
1 | .loader {
2 | display: inline-block;
3 | position: relative;
4 | width: 12px;
5 | height: 12px;
6 | }
7 |
8 | .loader div {
9 | box-sizing: border-box;
10 | display: block;
11 | position: absolute;
12 | width: 100%;
13 | height: 100%;
14 | margin: 0;
15 | border: 1px solid transparent;
16 | border-radius: 50%;
17 | animation: loading 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
18 | border-color: transparent transparent transparent transparent;
19 | }
20 |
21 | .loader div:nth-child(1) {
22 | animation-delay: -0.45s;
23 | }
24 |
25 | .loader div:nth-child(2) {
26 | animation-delay: -0.3s;
27 | }
28 |
29 | .loader div:nth-child(3) {
30 | animation-delay: -0.15s;
31 | }
32 |
33 | @keyframes loading {
34 | 0% {
35 | transform: rotate(0deg);
36 | }
37 | 100% {
38 | transform: rotate(360deg);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/dashboard/components/table/TableCells.module.css:
--------------------------------------------------------------------------------
1 | .progressBarWrapper {
2 | display: flex;
3 | align-items: center;
4 | height: 1.25em;
5 | min-width: 48px;
6 | margin-right: 8px;
7 | }
8 |
9 | .progressBarBg {
10 | background: #e5e7eb;
11 | border-radius: 4px;
12 | width: 40px;
13 | height: 6px;
14 | overflow: hidden;
15 | position: relative;
16 | }
17 |
18 | .progressBarFill {
19 | background: #1a2aff;
20 | height: 100%;
21 | border-radius: 4px;
22 | transition: width 0.2s;
23 | }
24 |
25 | .combined {
26 | display: flex;
27 | align-items: center;
28 | gap: 4px;
29 | }
30 |
31 | .delta {
32 | font-size: 0.95em;
33 | margin-left: 4px;
34 | display: inline-flex;
35 | align-items: center;
36 | }
37 |
38 | .deltaPositive {
39 | color: #16a34a;
40 | }
41 |
42 | .deltaNegative {
43 | color: #dc2626;
44 | }
45 |
46 | .deltaNeutral {
47 | color: #6b7280;
48 | }
--------------------------------------------------------------------------------
/dashboard/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require('tailwindcss/defaultTheme')
2 | const { colors, typography } = require('./styles/theme')
3 |
4 | /** @type {import('tailwindcss').Config} */
5 |
6 | module.exports = {
7 | content: [
8 | './app/**/*.{js,ts,jsx,tsx}',
9 | './components/**/*.{js,ts,jsx,tsx}',
10 | ],
11 | theme: {
12 | fontFamily: {
13 | sans: [typography.fontFamily, ...fontFamily.sans],
14 | },
15 | extend: {
16 | colors,
17 | textColor: {
18 | base: '#25283D',
19 | },
20 | fontSize: {
21 | md: '1rem',
22 | },
23 | gridTemplateRows: {
24 | '2-auto': 'repeat(2, auto)',
25 | '3-auto': 'repeat(3, auto)',
26 | },
27 | },
28 | },
29 | plugins: [
30 | function ({ addVariant }) {
31 | addVariant('state-active', '&[data-state="active"]')
32 | },
33 | ],
34 | }
35 |
--------------------------------------------------------------------------------
/dashboard/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true,
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ],
26 | "paths": {
27 | "@/*": ["./*"]
28 | }
29 | },
30 | "include": [
31 | "next-env.d.ts",
32 | "**/*.ts",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts"
35 | ],
36 | "exclude": [
37 | "node_modules",
38 | "**/*.cy.ts"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/AIChatStandalone.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { AIChatProvider, AIChatContainer } from './index'
5 |
6 | interface AIChatStandaloneProps {
7 | placeholder?: string
8 | className?: string
9 | maxSteps?: number
10 | }
11 |
12 | /**
13 | * Standalone AI Chat component that can be embedded anywhere
14 | *
15 | * Usage:
16 | * ```tsx
17 | *
22 | * ```
23 | */
24 | export function AIChatStandalone({
25 | placeholder,
26 | className = "",
27 | maxSteps = 30
28 | }: AIChatStandaloneProps) {
29 | return (
30 |
35 | )
36 | }
--------------------------------------------------------------------------------
/.github/workflows/release-workflow.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: published
6 |
7 | jobs:
8 | script:
9 | name: "Publish tracking script to npm"
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v1
14 | - name: Use Node.js
15 | uses: actions/setup-node@v4
16 | with:
17 | node-version: 22.x
18 | - name: npm install, build, and test
19 | run: |
20 | npm install --prefix middleware
21 | npm run lint --prefix middleware --if-present
22 | npm run test:coverage --prefix middleware --if-present
23 | npm run build --prefix middleware
24 | env:
25 | CI: true
26 | - name: npm publish
27 | run: |
28 | npm config set //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN
29 | npm publish ./middleware --access public
30 | env:
31 | CI: true
32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
33 |
--------------------------------------------------------------------------------
/tinybird/materializations/tenant_actions.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Materializes distinct actions by tenant and domain from analytics events
3 |
4 | NODE tenant_actions_node
5 | DESCRIPTION >
6 | Aggregate distinct actions per tenant/domain with last payload sample
7 |
8 | SQL >
9 | with multiIf(domain != '', domain, current_domain != '', current_domain, domain_from_payload) as domain,
10 | JSONExtractString(payload, 'domain') as domain_from_payload,
11 | if(domainWithoutWWW(href) = '' and href is not null and href != '', URLHierarchy(href)[1], domainWithoutWWW(href)) as current_domain,
12 | JSONExtractString(payload, 'href') as href
13 | SELECT
14 | tenant_id,
15 | domain,
16 | action,
17 | anySimpleState(payload) AS last_payload,
18 | maxSimpleState(timestamp) AS last_seen,
19 | countState() AS total_occurrences
20 | FROM analytics_events
21 | GROUP BY tenant_id, domain, action
22 |
23 | TYPE MATERIALIZED
24 | DATASOURCE tenant_actions_mv
--------------------------------------------------------------------------------
/tinybird/endpoints/actions.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Use to understand the different action types (page_hit, web_vital, etc.) and a sample payload for each tenant and domain
3 | Filter by action_filter to not get the whole table, it's a SQL like filter (e.g. "%web_%")
4 |
5 | TOKEN "dashboard" READ
6 |
7 | NODE endpoint
8 | DESCRIPTION >
9 | Get distinct actions for a specific tenant across all domains
10 |
11 | SQL >
12 | %
13 | SELECT
14 | domain,
15 | action,
16 | anySimpleState(last_payload) AS last_payload,
17 | maxSimpleState(last_seen) AS last_seen,
18 | countMerge(total_occurrences) AS total_occurrences
19 | FROM tenant_actions_mv
20 | WHERE tenant_id = {{ String(tenant_id, description="Tenant ID to filter actions for", default="") }}
21 | {% if defined(action_filter) %}
22 | AND action like {{String(action_filter, description="A like filter to search for actions", example="%cli_%")}}
23 | {% end %}
24 | GROUP BY domain, action
25 | ORDER BY last_seen DESC, domain ASC, action ASC
26 |
27 | TYPE endpoint
--------------------------------------------------------------------------------
/middleware/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tinybirdco/flock.js",
3 | "version": "1.7.0",
4 | "main": "dist/index.js",
5 | "description": "Tinybird web analytics tracking script",
6 | "author": "Tinybird team",
7 | "source": [
8 | "src/index.html"
9 | ],
10 | "targets": {
11 | "main": false
12 | },
13 | "scripts": {
14 | "start": "parcel",
15 | "watch": "parcel watch",
16 | "build": "parcel build --no-source-maps"
17 | },
18 | "license": "MIT",
19 | "devDependencies": {
20 | "parcel": "^2.7.0"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "https://github.com/tinybirdco/web-analytics-starter-kit"
25 | },
26 | "dependencies": {
27 | "node-fetch": "^2.6.6",
28 | "web-vitals": "^5.0.3"
29 | },
30 | "keywords": [
31 | "tinybird",
32 | "analytics",
33 | "library",
34 | "data",
35 | "operational"
36 | ],
37 | "bugs": {
38 | "url": "https://github.com/tinybirdco/web-analytics-starter-kit/issues"
39 | },
40 | "homepage": "https://github.com/tinybirdco/web-analytics-starter-kit#README"
41 | }
42 |
--------------------------------------------------------------------------------
/.github/workflows/tinybird-ci.yml:
--------------------------------------------------------------------------------
1 |
2 | name: Tinybird - CI Workflow
3 |
4 | on:
5 | workflow_dispatch:
6 | pull_request:
7 | branches:
8 | - main
9 | types: [opened, reopened, labeled, unlabeled, synchronize]
10 |
11 | concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }}
12 |
13 | env:
14 | TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }}
15 | TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }}
16 |
17 | jobs:
18 | ci:
19 | runs-on: ubuntu-latest
20 | defaults:
21 | run:
22 | working-directory: 'tinybird'
23 | services:
24 | tinybird:
25 | image: tinybirdco/tinybird-local:latest
26 | ports:
27 | - 7181:7181
28 | steps:
29 | - uses: actions/checkout@v3
30 | - name: Install Tinybird CLI
31 | run: curl https://tinybird.co | sh
32 | - name: Build project
33 | run: tb build
34 | - name: Test project
35 | run: tb test run
36 | # - name: Deployment check
37 | # run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Tinybird.co
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/ci-workflow.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [ push ]
4 |
5 | jobs:
6 | dashboard:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v1
11 | - name: Use Node.js 22
12 | uses: actions/setup-node@v4
13 | with:
14 | node-version: 22.x
15 | cache: 'npm'
16 | cache-dependency-path: dashboard/package-lock.json
17 | - name: npm install, build
18 | run: |
19 | npm install --prefix dashboard
20 | npm run lint --prefix dashboard --if-present
21 | npm run build --prefix dashboard
22 | env:
23 | CI: true
24 |
25 | script:
26 | runs-on: ubuntu-latest
27 |
28 | steps:
29 | - uses: actions/checkout@v1
30 | - name: Use Node.js 22
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: 22.x
34 | cache: 'npm'
35 | cache-dependency-path: middleware/package-lock.json
36 | - name: npm install, build
37 | run: |
38 | npm install --prefix middleware
39 | npm run lint --prefix middleware --if-present
40 | npm run build --prefix middleware
41 | env:
42 | CI: true
43 |
--------------------------------------------------------------------------------
/dashboard/mocks/handlers.ts:
--------------------------------------------------------------------------------
1 | import { http } from 'msw'
2 |
3 | export const data = {
4 | meta: [
5 | {
6 | name: 't',
7 | type: 'DateTime',
8 | },
9 | {
10 | name: 'visits',
11 | type: 'UInt64',
12 | },
13 | ],
14 | data: [
15 | {
16 | t: '2022-09-14 22:57:00',
17 | visits: 1,
18 | },
19 | {
20 | t: '2022-09-14 22:58:00',
21 | visits: 1,
22 | },
23 | {
24 | t: '2022-09-14 22:59:00',
25 | visits: 2,
26 | },
27 | {
28 | t: '2022-09-14 23:00:00',
29 | visits: 1,
30 | },
31 | {
32 | t: '2022-09-14 23:01:00',
33 | visits: 2,
34 | },
35 | ],
36 | rows: 30,
37 | statistics: {
38 | elapsed: 0.003924389,
39 | rows_read: 5137,
40 | bytes_read: 338977,
41 | },
42 | }
43 |
44 | // Define handlers that catch the corresponding requests and returns the mock data.
45 | export const handlers = [
46 | http.get('https://analytics-api.com/v0/pipes/trend.json', ({ request, params, cookies }) => {
47 | return new Response(
48 | JSON.stringify(data),
49 | {
50 | headers: { 'Content-Type': 'application/json' },
51 | status: 200,
52 | }
53 | )
54 | }),
55 | ]
56 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Badge.module.css:
--------------------------------------------------------------------------------
1 | .base {
2 | display: inline-flex;
3 | align-items: center;
4 | justify-content: center;
5 | white-space: nowrap;
6 | border-radius: 48px;
7 | font-size: 12px;
8 | line-height: 16px;
9 | font-weight: 500;
10 | transition: all 0.2s ease;
11 | border: 1px solid transparent;
12 | font-family: var(--font-family-mono);
13 | }
14 |
15 | /* Size variants */
16 | .sizeSmall {
17 | padding: 2px 6px;
18 | font-size: 11px;
19 | line-height: 14px;
20 | }
21 |
22 | .sizeMedium {
23 | padding: 4px 8px;
24 | font-size: 12px;
25 | line-height: 16px;
26 | }
27 |
28 | .sizeLarge {
29 | padding: 6px 12px;
30 | font-size: 14px;
31 | line-height: 18px;
32 | }
33 |
34 | /* Color variants */
35 | .default {
36 | background-color: var(--background-02-color, #f3f4f6);
37 | color: var(--text-02-color, #6b7280);
38 | }
39 |
40 | .success {
41 | background-color: #f0fdf4;
42 | color: var(--text-brand-dark-color);
43 | }
44 |
45 | .warning {
46 | background-color: #fffbeb;
47 | color: #d97706;
48 | }
49 |
50 | .error {
51 | background-color: #fef2f2;
52 | color: var(--text-error-color);
53 | }
54 |
55 | .info {
56 | background-color: #eff6ff;
57 | color: #2563eb;
58 | }
59 |
--------------------------------------------------------------------------------
/dashboard/lib/hooks/use-current-visitors.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 | import { querySQL, queryPipe, getConfig } from '../api'
3 | import { useSearchParams } from 'next/navigation'
4 |
5 | async function getCurrentVisitors(domainParam?: string): Promise {
6 | const { token } = getConfig();
7 | let data;
8 | const domainFilter = domainParam && domainParam !== 'ALL' ? { domain: domainParam } : {}
9 | if (token && token.startsWith('p.ey')) {
10 | let where = 'timestamp >= (now() - interval 5 minute)'
11 | if (domainParam && domainParam !== 'ALL') {
12 | where += ` AND domain = '${domainParam}'`
13 | }
14 | const sql = `SELECT uniq(session_id) AS visits FROM analytics_hits WHERE ${where} FORMAT JSON`
15 | data = (await querySQL<{ visits: number }[]>(sql)).data;
16 | } else {
17 | data = (await queryPipe<{ visits: number }[]>('current_visitors', domainFilter)).data;
18 | }
19 | const [row] = data || []
20 | return row?.visits ?? 0
21 | }
22 |
23 | export default function useCurrentVisitors() {
24 | const searchParams = useSearchParams()
25 | const domainParam = searchParams.get('domain') || undefined
26 | const { data } = useSWR(['current_visitors', domainParam], () => getCurrentVisitors(domainParam))
27 | return data ?? 0
28 | }
29 |
--------------------------------------------------------------------------------
/dashboard/lib/hooks/use-endpoint.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 | import { queryPipe, getConfig } from '../api'
3 | import { useSearchParams } from 'next/navigation'
4 |
5 | export function useEndpoint(
6 | pipeName: string,
7 | params: Record = {}
8 | ) {
9 | const searchParams = useSearchParams()
10 |
11 | let mergedParams = { ...params }
12 | if (searchParams) {
13 | const date_from = searchParams.get('date_from')
14 | const date_to = searchParams.get('date_to')
15 | const domain = searchParams.get('domain')
16 | if (date_from && !('date_from' in params)) mergedParams.date_from = date_from
17 | if (date_to && !('date_to' in params)) mergedParams.date_to = date_to
18 | if (domain && domain !== 'ALL' && !('domain' in params)) mergedParams.domain = domain
19 | }
20 |
21 | const fetcher = async () => {
22 | const { data } = await queryPipe(pipeName, mergedParams)
23 | return data
24 | }
25 |
26 | const { data, error, isLoading, mutate } = useSWR(
27 | [pipeName, mergedParams],
28 | fetcher,
29 | {
30 | revalidateOnFocus: false,
31 | revalidateOnReconnect: false,
32 | }
33 | )
34 |
35 | return {
36 | data,
37 | error,
38 | isLoading,
39 | mutate,
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tinybird/copies/random_data_generator.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Copy pipe that generates random analytics events data with configurable row limit and date range parameters for testing purposes
3 |
4 | NODE generate_random_events
5 | SQL >
6 | %
7 | SELECT
8 | now() - interval rand() % ({{ Int32(days_back, 7, description="Number of days back to generate timestamps for") }} * 86400) second as timestamp,
9 | concat('session_', toString(rand() % 10000)) as session_id,
10 | arrayElement(['page_hit', 'web_vital', 'click', 'scroll'], (rand() % 4) + 1) as action,
11 | arrayElement(['1.0', '1.1', '2.0'], (rand() % 3) + 1) as version,
12 | concat('{"href":"https://example.com/', arrayElement(['home', 'about', 'contact', 'products', 'blog'], (rand() % 5) + 1), '","domain":"example.com","pathname":"/', arrayElement(['home', 'about', 'contact', 'products', 'blog'], (rand() % 5) + 1), '","user-agent":"Mozilla/5.0 (compatible; test)"}') as payload,
13 | concat('tenant_', toString((rand() % 5) + 1)) as tenant_id,
14 | arrayElement(['example.com', 'test.com', 'demo.org'], (rand() % 3) + 1) as domain
15 | FROM numbers({{ Int32(row_limit, 1000, description="Number of random rows to generate") }})
16 |
17 | TYPE COPY
18 | TARGET_DATASOURCE analytics_events
--------------------------------------------------------------------------------
/dashboard/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import AnalyticsProvider from '../components/Provider'
3 | import '../styles/globals.css'
4 | import { Suspense } from 'react'
5 | import { Inter } from 'next/font/google'
6 | import localFont from 'next/font/local'
7 | import { TooltipProvider } from '@/components/ui/Tooltip'
8 |
9 | const sans = Inter({
10 | variable: '--font-family-sans',
11 | subsets: ['latin'],
12 | fallback: ['system-ui'],
13 | })
14 |
15 | const mono = localFont({
16 | variable: '--font-family-iawriter',
17 | src: '../assets/fonts/iawritermonos-regular.woff2',
18 | display: 'swap',
19 | })
20 |
21 | export const metadata: Metadata = {
22 | title: 'Tinybird Analytics Dashboard',
23 | description:
24 | 'Create in-product analytics or internal dashboards in minutes with Tinybird Charts',
25 | }
26 |
27 | export default function RootLayout({
28 | children,
29 | }: {
30 | children: React.ReactNode
31 | }) {
32 | return (
33 |
34 |
35 |
36 |
37 | {children}
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/dashboard/app/widgets/visitors.tsx:
--------------------------------------------------------------------------------
1 | import { SqlChart } from '@/components/ui/SqlChart'
2 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
3 | import { useTimeRange } from '@/lib/hooks/use-time-range'
4 | import { Card } from '@/components/ui/Card'
5 |
6 | // Helper function to determine date format based on time range
7 | const getAxisDateFormat = (timeRange: string): string => {
8 | // For today or yesterday, show time format (9:15pm)
9 | if (timeRange === 'today' || timeRange === 'yesterday') {
10 | return 'p'
11 | }
12 | // For all other ranges, show date format (Jun 3)
13 | return 'MMM d'
14 | }
15 |
16 | export const Visitors = () => {
17 | const { value: timeRange } = useTimeRange()
18 | const { data, error, isLoading } = useEndpoint<
19 | { visits: number; pageviews: number }[]
20 | >('kpis', { include_previous_period: true })
21 |
22 | return (
23 |
24 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/dashboard/app/widgets/pageviews.tsx:
--------------------------------------------------------------------------------
1 | import { SqlChart } from '@/components/ui/SqlChart'
2 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
3 | import { useTimeRange } from '@/lib/hooks/use-time-range'
4 | import { Card } from '@/components/ui/Card'
5 |
6 | // Helper function to determine date format based on time range
7 | const getAxisDateFormat = (timeRange: string): string => {
8 | // For today or yesterday, show time format (9:15pm)
9 | if (timeRange === 'today' || timeRange === 'yesterday') {
10 | return 'p'
11 | }
12 | // For all other ranges, show date format (Jun 3)
13 | return 'MMM d'
14 | }
15 |
16 | export const Pageviews = () => {
17 | const { value: timeRange } = useTimeRange()
18 | const { data, error, isLoading } = useEndpoint<
19 | { visits: number; pageviews: number }[]
20 | >('kpis', { include_previous_period: true })
21 |
22 | return (
23 |
24 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/tinybird/endpoints/trend.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Visits trend over time for the last 30 minutes, filling the blanks.
3 | Works great for the realtime chart.
4 |
5 | TOKEN "dashboard" READ
6 |
7 | NODE timeseries
8 | DESCRIPTION >
9 | Generate a timeseries for the last 30 minutes, so we call fill empty data points
10 |
11 | SQL >
12 | with (now() - interval 30 minute) as start
13 | select addMinutes(toStartOfMinute(start), number) as t
14 | from (select arrayJoin(range(1, 31)) as number)
15 |
16 | NODE hits
17 | DESCRIPTION >
18 | Get last 30 minutes metrics gropued by minute
19 |
20 | SQL >
21 | %
22 | select toStartOfMinute(timestamp) as t, uniq(session_id) as visits
23 | from analytics_hits
24 | where timestamp >= (now() - interval 30 minute)
25 | {% if defined(tenant_id) %}
26 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
27 | {% end %}
28 | {% if defined(domain) %}
29 | AND domain = {{ String(domain, description="Filter by domain") }}
30 | {% end %}
31 | group by toStartOfMinute(timestamp)
32 | order by toStartOfMinute(timestamp)
33 |
34 | NODE endpoint
35 | DESCRIPTION >
36 | Join and generate timeseries with metrics for the last 30 minutes
37 |
38 | SQL >
39 | select a.t, b.visits from timeseries a left join hits b on a.t = b.t order by a.t
40 | TYPE endpoint
--------------------------------------------------------------------------------
/dashboard/lib/types.ts:
--------------------------------------------------------------------------------
1 | export type Node = {
2 | id: string
3 | name: string
4 | sql: string | null
5 | dependencies: string[] | null
6 | params: Parameter[] | null
7 | node_type:
8 | | 'standard'
9 | | 'materialized'
10 | | 'endpoint'
11 | | 'copy'
12 | | 'sink'
13 | | 'stream'
14 | | 'timeseries'
15 | updated_at: string
16 | created_at: string
17 | description: string | null
18 | materialized?: string | null
19 | tags?: Record
20 | }
21 |
22 | export type ParameterValue = string | number | null
23 |
24 | export type Parameter = {
25 | name: string
26 | description?: string
27 | required?: boolean
28 | type?: string
29 | default?: ParameterValue
30 | inheritedPipes?: Pipe[]
31 | nodes?: string[]
32 | defaults?: string[]
33 | }
34 |
35 | export type Pipe = {
36 | id: string
37 | name: string
38 | description: string | null
39 | type: 'endpoint' | 'copy' | 'materialized' | 'sink' | 'stream' | 'default'
40 | created_at: string
41 | updated_at: string
42 | nodes?: Node[] | null
43 | copy_mode?: 'append' | 'replace'
44 | copy_node?: string
45 | copy_target_datasource?: string
46 | schedule?: {
47 | cron: string | null
48 | status: 'shutdown' | 'running'
49 | }
50 | endpoint?: string | null
51 | content?: string | null
52 | }
53 |
54 | export type QueryData = Record[]
55 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Stack.tsx:
--------------------------------------------------------------------------------
1 | const SPACING_UNIT = 8
2 |
3 | export type StackProps = React.ComponentPropsWithoutRef<'div'> & {
4 | children: React.ReactNode
5 | direction?: 'row' | 'column' | 'row-reverse' | 'column-reverse'
6 | spacing?: number
7 | gap?: number
8 | justify?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around'
9 | align?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'
10 | wrap?: 'wrap' | 'nowrap' | 'wrap-reverse'
11 | width?: string | number
12 | height?: string | number
13 | minWidth?: string | number
14 | as?: React.ElementType
15 | }
16 |
17 | export function Stack({
18 | children,
19 | style,
20 | direction = 'row',
21 | justify = 'flex-start',
22 | align = direction === 'column' ? 'flex-start' : 'center',
23 | wrap = 'nowrap',
24 | spacing = 1,
25 | gap = spacing * SPACING_UNIT,
26 | width,
27 | minWidth = 0,
28 | height,
29 | as: Component = 'div',
30 | ...props
31 | }: StackProps) {
32 | return (
33 |
49 | {children}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/middleware/api/tracking.js:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 |
3 | export const config = {
4 | runtime: 'experimental-edge',
5 | };
6 |
7 | const DATASOURCE = 'analytics_events';
8 |
9 | /**
10 | * Post event to Tinybird HFI
11 | *
12 | * @param { string } event Event object
13 | * @return { Promise } Tinybird HFI response
14 | */
15 | const _postEvent = async event => {
16 | const options = {
17 | method: 'post',
18 | body: event,
19 | headers: {
20 | 'Authorization': `Bearer ${process.env.TINYBIRD_TOKEN}`,
21 | 'Content-Type': 'application/json'
22 | }
23 | };
24 | const response = await fetch(`https://api.tinybird.co/v0/events?name=${DATASOURCE}`, options);
25 | if (!response.ok) {
26 | throw response.statusText;
27 | }
28 |
29 | return response.json();
30 | };
31 |
32 | export default async (req) => {
33 | await _postEvent(req.body);
34 | return new Response('ok', {
35 | headers: {
36 | 'access-control-allow-credentials': true,
37 | 'access-control-allow-origin': process.env.CORS_ALLOW_ORIGIN || '*',
38 | 'access-control-allow-methods': 'OPTIONS,POST',
39 | 'access-control-allow-headers': 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version',
40 | 'content-type': 'text/html'
41 | },
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/dashboard/components/Provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
4 | import { SWRConfig } from 'swr'
5 | import { QueryError } from '../lib/types/api'
6 |
7 | type IAnalyticsContext = {
8 | error: QueryError | null
9 | setError: (error: QueryError | null) => void
10 | }
11 |
12 | const AnalyticsContext = createContext(
13 | {} as IAnalyticsContext
14 | )
15 |
16 | export default function AnalyticsProvider({
17 | children,
18 | }: {
19 | children: ReactNode
20 | }) {
21 | const [error, setError] = useState(null)
22 | const value = useMemo(() => ({ error, setError }), [error])
23 |
24 | return (
25 | {
32 | if (error.status === 401 || error.status === 403) {
33 | setError(error)
34 | }
35 | },
36 | }}
37 | >
38 |
39 | {children}
40 |
41 |
42 | )
43 | }
44 |
45 | export function useAnalytics() {
46 | const context = useContext(AnalyticsContext)
47 | if (!context)
48 | throw new Error('useAnalytics must be used within an AnalyticsProvider')
49 | return context
50 | }
51 |
--------------------------------------------------------------------------------
/dashboard/components/ui/TimeRangeSelect.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Fragment } from 'react'
4 | import {
5 | SelectContent,
6 | SelectItem,
7 | SelectRoot,
8 | SelectSeparator,
9 | SelectTrigger,
10 | SelectValue,
11 | } from './Select'
12 |
13 | export function TimeRangeSelect({
14 | value,
15 | onChange,
16 | options,
17 | className,
18 | style,
19 | }: {
20 | value: string
21 | onChange: (value: string) => void
22 | options: { label: string; value: string; separator?: boolean }[]
23 | className?: string
24 | style?: React.CSSProperties
25 | }) {
26 | const defaultValue = (options.find(tr => tr.value === value) || options[2])
27 | .value
28 | return (
29 |
34 |
35 |
36 | {['today', 'yesterday'].includes(value) ? '' : 'Last '}
37 |
38 |
39 |
40 |
41 | {options.map(option => (
42 |
43 | {option.label}
44 | {option.separator && }
45 |
46 | ))}
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Dialog.module.css:
--------------------------------------------------------------------------------
1 | .overlay {
2 | position: fixed;
3 | inset: 0;
4 | z-index: 50;
5 | background-color: rgba(37, 40, 61, 0.8);
6 | }
7 |
8 | .content {
9 | position: fixed;
10 | left: 50%;
11 | top: 50%;
12 | z-index: 50;
13 | display: grid;
14 | width: 100%;
15 | max-width: 512px;
16 | translate: -50% -50%;
17 | gap: 24px;
18 | border: 1px solid var(--border-01-color);
19 | background-color: var(--neutral-00-color);
20 | padding: 32px;
21 | box-shadow: 0px 12px 24px rgba(0, 0, 0, 0.32);
22 | color: var(--text-color);
23 | outline: none;
24 | }
25 |
26 | @media (min-width: 640px) {
27 | .content {
28 | top: 92px;
29 | translate: -50% 0;
30 | min-width: 680px;
31 | width: unset;
32 | max-width: 80%;
33 | border-radius: 4px;
34 | }
35 | }
36 |
37 | .header {
38 | display: flex;
39 | flex-direction: column;
40 | gap: 0.375rem;
41 | text-align: center;
42 | }
43 |
44 | @media (min-width: 640px) {
45 | .header {
46 | text-align: left;
47 | }
48 | }
49 |
50 | .footer {
51 | display: flex;
52 | flex-direction: column-reverse;
53 | }
54 |
55 | @media (min-width: 640px) {
56 | .footer {
57 | flex-direction: row;
58 | justify-content: flex-end;
59 | gap: 0.5rem;
60 | }
61 | }
62 |
63 | .title {
64 | font: var(--font-displayxsmall);
65 | color: var(--text-color);
66 | }
67 |
68 | .description {
69 | font: var(--font-body);
70 | color: var(--text-color);
71 | }
72 |
--------------------------------------------------------------------------------
/dashboard/lib/hooks/use-domain.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import useSWR from 'swr'
3 | import { querySQL, queryPipe, getConfig } from '../api'
4 |
5 | async function getDomain(): Promise {
6 | const { token } = getConfig();
7 | let data;
8 | if (token && token.startsWith('p.ey')) {
9 | // Use SQL for 'dashboard' tokens
10 | ({ data } = await querySQL(`
11 | with (
12 | SELECT nullif(domainWithoutWWW(href),'') as domain
13 | FROM analytics_hits
14 | where timestamp >= now() - interval 1 hour
15 | group by domain
16 | order by count(1) desc
17 | limit 1
18 | ) as top_domain,
19 | (
20 | SELECT domainWithoutWWW(href)
21 | FROM analytics_hits
22 | where href not like '%localhost%'
23 | limit 1
24 | ) as some_domain
25 | select coalesce(top_domain, some_domain) as domain format JSON
26 | `));
27 | } else {
28 | // Use pipe for non-dashboard tokens
29 | ({ data } = await queryPipe('domain'));
30 | }
31 | const domain = data[0]['domain'];
32 | const logo = domain
33 | ? `https://${domain}/favicon.ico`
34 | : FALLBACK_LOGO
35 |
36 | return {
37 | domain,
38 | logo,
39 | }
40 | }
41 |
42 | const FALLBACK_LOGO = '/fallback-logo.png'
43 |
44 | export default function useDomain() {
45 | const [logo, setLogo] = useState(FALLBACK_LOGO)
46 |
47 | const { data } = useSWR('domain', getDomain, {
48 | onSuccess: ({ logo }) => setLogo(logo),
49 | })
50 |
51 | const handleLogoError = () => {
52 | setLogo(FALLBACK_LOGO)
53 | }
54 |
55 | return {
56 | domain: data?.domain ?? 'domain.com',
57 | logo,
58 | handleLogoError,
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/tinybird/web_vitals/endpoints/web_vitals_timeseries.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Hourly time series of web vitals quantile values (0.75, 0.9, 0.95, 0.99) for each metric. Shows performance distribution over time with configurable time period and optional domain/tenant filtering.
3 |
4 | TOKEN "dashboard" READ
5 |
6 | NODE filtered_vitals
7 | DESCRIPTION >
8 | Filter web vitals data for the specified period
9 |
10 | SQL >
11 | %
12 | SELECT
13 | toStartOfHour(timestamp) as hour,
14 | metric_name,
15 | value,
16 | domain,
17 | tenant_id
18 | FROM web_vitals_events
19 | WHERE 1=1
20 | {% if defined(date_from) %}
21 | AND timestamp >= toDateTime(concat(toString({{ Date(date_from) }}), ' 00:00:00'))
22 | {% end %}
23 | {% if defined(date_to) %}
24 | AND timestamp <= toDateTime(concat(toString({{ Date(date_to) }}), ' 23:59:59'))
25 | {% end %}
26 | {% if defined(tenant_id) %}
27 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
28 | {% end %}
29 | {% if defined(domain) %}
30 | AND domain = {{ String(domain, description="Domain to filter web vitals for") }}
31 | {% end %}
32 |
33 | NODE endpoint
34 | DESCRIPTION >
35 | Calculate quantiles for each metric per hour
36 |
37 | SQL >
38 | SELECT
39 | hour,
40 | metric_name,
41 | quantile(0.75)(value) as p75,
42 | quantile(0.90)(value) as p90,
43 | quantile(0.95)(value) as p95,
44 | quantile(0.99)(value) as p99,
45 | count() as measurements,
46 | domain
47 | FROM filtered_vitals
48 | GROUP BY hour, metric_name, domain
49 | HAVING measurements >= 5
50 | ORDER BY hour ASC, metric_name ASC
51 |
52 | TYPE endpoint
--------------------------------------------------------------------------------
/dashboard/components/ui/Badge.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { cn } from '@/lib/utils'
5 | import { cva, type VariantProps } from 'class-variance-authority'
6 | import styles from './Badge.module.css'
7 | import { ArrowDownRightIcon, ArrowUpLeft, ArrowUpRightIcon } from 'lucide-react'
8 |
9 | const badgeVariants = cva(styles.base, {
10 | variants: {
11 | variant: {
12 | default: styles.default,
13 | success: styles.success,
14 | warning: styles.warning,
15 | error: styles.error,
16 | info: styles.info,
17 | },
18 | size: {
19 | small: styles.sizeSmall,
20 | medium: styles.sizeMedium,
21 | large: styles.sizeLarge,
22 | },
23 | },
24 | defaultVariants: {
25 | variant: 'default',
26 | size: 'medium',
27 | },
28 | })
29 |
30 | export type BadgeProps = React.ComponentProps<'span'> &
31 | VariantProps & {
32 | delta?: number
33 | showSign?: boolean
34 | }
35 |
36 | export const Badge = React.forwardRef(
37 | (
38 | { className, variant, size, delta, showSign = true, children, ...props },
39 | ref
40 | ) => {
41 | const renderContent = () => {
42 | if (delta !== undefined) {
43 | return `${delta.toLocaleString()}%`
44 | }
45 |
46 | return children
47 | }
48 |
49 | return (
50 |
55 | {delta ? (
56 | <>
57 | {delta > 0 && }
58 | {delta < 0 && }
59 | >
60 | ) : null}
61 |
62 | {renderContent()}
63 |
64 | )
65 | }
66 | )
67 |
68 | Badge.displayName = 'Badge'
69 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/example-usage.tsx:
--------------------------------------------------------------------------------
1 | // Example usage of AI Chat components
2 |
3 | import React from 'react'
4 | import {
5 | AIChatStandalone,
6 | AIChatProvider,
7 | AIChatForm,
8 | AIChatContainer
9 | } from './index'
10 |
11 | // Example 1: Simple standalone usage
12 | export function SimpleExample() {
13 | return (
14 |
15 |
Analytics Dashboard
16 |
17 |
18 | )
19 | }
20 |
21 | // Example 2: Custom styling
22 | export function CustomStyledExample() {
23 | return (
24 |
31 | )
32 | }
33 |
34 | // Example 3: Advanced usage with custom layout
35 | export function AdvancedExample() {
36 | return (
37 |
38 |
39 |
Analytics Dashboard
40 |
41 |
42 |
43 |
44 |
45 |
Chat History
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | // Example 4: Embedding in a modal or sidebar
55 | export function ModalExample() {
56 | return (
57 |
58 |
59 |
AI Assistant
60 |
64 |
65 |
66 | )
67 | }
--------------------------------------------------------------------------------
/dashboard/lib/constants/metrics.ts:
--------------------------------------------------------------------------------
1 | // Metric thresholds for Core Web Vitals (ms for all except CLS)
2 | export const METRIC_THRESHOLDS: Record = {
3 | TTFB: { excellent: 500, good: 1000, poor: 1000 },
4 | FCP: { excellent: 1800, good: 3000, poor: 3000 },
5 | LCP: { excellent: 2500, good: 4000, poor: 4000 },
6 | CLS: { excellent: 0.1, good: 0.25, poor: 0.25 },
7 | INP: { excellent: 200, good: 500, poor: 500 },
8 | };
9 |
10 | // Helper function to get limits array [excellent, good] for charts
11 | export const getMetricLimits = (metricName: string): [number, number] => {
12 | const thresholds = METRIC_THRESHOLDS[metricName.toUpperCase()];
13 | if (!thresholds) {
14 | return [1000, 2000]; // fallback values
15 | }
16 | return [thresholds.good, thresholds.excellent];
17 | };
18 |
19 | export const METRIC_DESCRIPTIONS = {
20 | TTFB: {
21 | name: 'Time to First Byte',
22 | unit: 'ms',
23 | description: 'Server latency, or time from request to first byte received by the browser.',
24 | },
25 | FCP: {
26 | name: 'First Contentful Paint',
27 | unit: 'ms',
28 | description: 'Time until any DOM content (e.g. text, image) is rendered on screen.',
29 | },
30 | LCP: {
31 | name: 'Largest Contentful Paint',
32 | unit: 'ms',
33 | description: 'Render time of the largest visible element.',
34 | },
35 | CLS: {
36 | name: 'Cumulative Layout Shift',
37 | unit: '',
38 | description: 'Visual stability, sum of unexpected layout shifts during load.',
39 | },
40 | INP: {
41 | name: 'Interaction to Next Paint',
42 | unit: 'ms',
43 | description: 'End-to-end latency for user input, from interaction to visual feedback.',
44 | },
45 | FID: {
46 | name: 'First Input Delay',
47 | unit: 'ms',
48 | description: 'Input responsiveness, delay between first interaction and handler execution.',
49 | },
50 | }
51 |
--------------------------------------------------------------------------------
/TEMPLATE.md:
--------------------------------------------------------------------------------
1 | The Web Analytics template helps you set up your own web analytics platform using [Tinybird](https://www.tinybird.co/)'s Events API and Endpoints.
2 |
3 | Built with privacy and speed as top priorities, this template lets you get real-time metrics in a pre-built dashboard in just a few minutes without any knowledge about Tinybird and extend it with custom events tailored to your specific use cases (eCommerce, marketing, etc.).
4 |
5 | ## Set up the project
6 |
7 | Fork the GitHub repository and deploy the data project to Tinybird.
8 |
9 | ```bash
10 | # select or create a new workspace
11 | tb login
12 |
13 | # deploy the template
14 | tb --cloud deploy --template https://github.com/tinybirdco/web-analytics-starter-kit/tree/main/tinybird
15 |
16 | # copy the dashboard token
17 | tb --cloud token copy dashboard
18 | ```
19 |
20 | [Deploy the dashboard](https://github.com/tinybirdco/web-analytics-starter-kit/blob/main/dashboard/README.md) to Vercel or use the hosted dashboard at https://analytics.tinybird.live/ using the Workspace `dashboard` [token](https://cloud.tinybird.co/tokens).
21 |
22 |
23 | ## Send events from your site
24 |
25 | Copy the snippet and paste it within your site `` section:
26 |
27 | ```html
28 |
33 | ```
34 |
35 | Get your `tracker` token from the [Tinybird dashboard](https://cloud.tinybird.co/tokens) or using the CLI:
36 |
37 | ```bash
38 | tb --cloud token copy tracker && TRACKER_TOKEN=$(pbpaste)
39 | ```
40 |
41 | Use the `data-host` attribute to set your Tinybird host URL. Defaults to `https://api.tinybird.co/`.
42 |
43 | ## Local development and mock data
44 |
45 | See the [Tinybird](https://github.com/tinybirdco/web-analytics-starter-kit/blob/main/tinybird/README.md) and [dashboard](https://github.com/tinybirdco/web-analytics-starter-kit/blob/main/dashboard/README.md) READMEs.
46 |
--------------------------------------------------------------------------------
/dashboard/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import numeral from 'numeral'
2 |
3 | export const cn = (...args: (string | undefined | false)[]) =>
4 | args.filter(Boolean).join(' ')
5 |
6 | export const cx = (...args: (string | undefined | false)[]) =>
7 | args.filter(Boolean).join(' ')
8 |
9 | export function kFormatter(value: number): string {
10 | return value > 999 ? `${(value / 1000).toFixed(1)}K` : String(value)
11 | }
12 |
13 | export function formatMinSec(totalSeconds: number) {
14 | if (isNaN(totalSeconds)) return '0s'
15 |
16 | const minutes = Math.floor(totalSeconds / 60)
17 | const seconds = Math.floor(totalSeconds % 60)
18 | const padTo2Digits = (value: number) => value.toString().padStart(2, '0')
19 | return `${minutes ? `${minutes}m` : ''} ${padTo2Digits(seconds)}s`
20 | }
21 |
22 | export function formatPercentage(value: number) {
23 | return `${value ? (value * 100).toFixed(2) : '0'}%`
24 | }
25 |
26 | export function formatNumber(
27 | value: number | null | undefined,
28 | format = '0.[0]a'
29 | ) {
30 | return numeral(value ?? 0).format(format)
31 | }
32 |
33 | export function formatBytes(value: number | null | undefined) {
34 | return formatNumber(value, '0.[0] b')
35 | }
36 |
37 | export function formatMilliseconds(ms: number): string {
38 | if (ms < 1000) return `${formatNumber(ms, '0.[00]a')}ms`
39 | const seconds = Math.floor(ms / 1000)
40 | if (seconds < 60) return `${formatNumber(seconds, '0.[00]a')}s`
41 | const minutes = Math.floor(seconds / 60)
42 | if (minutes < 60) return `${formatNumber(minutes, '0.[00]a')}m`
43 | const hours = Math.floor(minutes / 60)
44 | return `${formatNumber(hours, '0.[00]a')}h`
45 | }
46 |
47 | type NumericKeys = {
48 | [K in keyof T]: T[K] extends number ? K : never
49 | }[keyof T]
50 |
51 | export const maxCellWidth = >(
52 | k: NumericKeys,
53 | arr: T[]
54 | ) => {
55 | const max = Math.max(...(arr || []).map(p => (p[k!] as number) || 0))
56 | return max.toLocaleString().length * 8.4
57 | }
58 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@/lib/utils'
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
5 | import * as React from 'react'
6 | import styles from './Tooltip.module.css'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 | const TooltipRoot = TooltipPrimitive.Root
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef & {
15 | variant?: 'dark' | 'light'
16 | }
17 | >(({ className, sideOffset = 4, variant = 'dark', ...props }, ref) => {
18 | return (
19 |
20 |
25 |
26 | )
27 | })
28 |
29 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
30 |
31 | export { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger }
32 |
33 | export function Tooltip({
34 | content,
35 | children,
36 | side = 'right',
37 | shortcut,
38 | asChild = true,
39 | variant = 'dark',
40 | }: {
41 | content: React.ReactNode
42 | children: React.ReactNode
43 | side?: TooltipPrimitive.TooltipContentProps['side']
44 | shortcut?: React.ReactNode
45 | asChild?: boolean
46 | variant?: 'dark' | 'light'
47 | }) {
48 | if (typeof content === 'undefined' || content === null) {
49 | return children
50 | }
51 |
52 | return (
53 |
54 | {children}
55 | {content && (
56 | e.preventDefault()}
59 | variant={variant}
60 | >
61 | {content}
62 | {shortcut && (
63 | {shortcut}
64 | )}
65 |
66 | )}
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Loader.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import styles from './Loader.module.css'
3 | import { LoaderCircleIcon, LoaderIcon } from 'lucide-react'
4 |
5 | export function Loader({
6 | className,
7 | color = 'currentColor',
8 | size = 11,
9 | width = 1,
10 | style
11 | }: {
12 | className?: string
13 | color?: string
14 | size?: number
15 | width?: number
16 | style?: React.CSSProperties
17 | }) {
18 | const borderColor = `${color} transparent transparent transparent`
19 | const borderWidth = `${width}px`
20 | return (
21 |
51 | )
52 | }
53 |
54 | export function Spinner() {
55 | return (
56 |
68 | )
69 | }
70 |
71 | export function SpinnerCircle() {
72 | return (
73 |
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/AIChatProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { createContext, useContext, ReactNode, useState } from 'react'
4 | import { useChat } from '@ai-sdk/react'
5 | import { useSearchParams } from 'next/navigation'
6 |
7 | interface AIChatContextType {
8 | messages: any[]
9 | input: string
10 | setInput: React.Dispatch>
11 | handleInputChange: (e: React.ChangeEvent) => void
12 | handleSubmit: (e: React.FormEvent) => void
13 | status: string
14 | isLoading: boolean
15 | error: any
16 | setMessages: (messages: any[] | ((messages: any[]) => any[])) => void
17 | lastSubmittedQuestion: string
18 | setLastSubmittedQuestion: (question: string) => void
19 | }
20 |
21 | const AIChatContext = createContext(undefined)
22 |
23 | interface AIChatProviderProps {
24 | children: ReactNode
25 | maxSteps?: number
26 | }
27 |
28 | export function AIChatProvider({
29 | children,
30 | maxSteps = 30,
31 | }: AIChatProviderProps) {
32 | const searchParams = useSearchParams()
33 | const token = searchParams?.get('token')
34 | const host = searchParams?.get('host')
35 |
36 | // Build the API URL with query parameters
37 | const apiUrl =
38 | token && host
39 | ? `${
40 | process.env.NEXT_PUBLIC_ASK_TINYBIRD_ENDPOINT
41 | }?token=${encodeURIComponent(token)}&host=${encodeURIComponent(host)}`
42 | : `${process.env.NEXT_PUBLIC_ASK_TINYBIRD_ENDPOINT}`
43 |
44 | const chatState = useChat({
45 | api: apiUrl,
46 | maxSteps,
47 | })
48 | const [lastSubmittedQuestion, setLastSubmittedQuestion] = useState('')
49 |
50 | return (
51 |
58 | {children}
59 |
60 | )
61 | }
62 |
63 | export function useAIChat() {
64 | const context = useContext(AIChatContext)
65 | if (context === undefined) {
66 | throw new Error('useAIChat must be used within an AIChatProvider')
67 | }
68 | return context
69 | }
70 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TabsPrimitive from '@radix-ui/react-tabs'
5 | import { cn } from '@/lib/utils'
6 | import styles from './Tabs.module.css'
7 |
8 | const Tabs = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithRef & {
11 | color?: 'dark'
12 | variant?: 'code' | 'pill'
13 | }
14 | >(({ className, color, variant, ...props }, ref) => {
15 | return (
16 |
26 | )
27 | })
28 |
29 | Tabs.displayName = TabsPrimitive.Root.displayName
30 |
31 | const TabsList = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithRef
34 | >(({ className, ...props }, ref) => {
35 | return (
36 |
41 | )
42 | })
43 |
44 | TabsList.displayName = TabsPrimitive.List.displayName
45 |
46 | const TabsTrigger = React.forwardRef<
47 | React.ElementRef,
48 | React.ComponentPropsWithRef
49 | >(({ className, ...props }, ref) => {
50 | return (
51 |
56 | )
57 | })
58 |
59 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
60 |
61 | const TabsContent = React.forwardRef<
62 | React.ElementRef,
63 | React.ComponentPropsWithRef
64 | >(({ className, ...props }, ref) => {
65 | return (
66 |
71 | )
72 | })
73 |
74 | TabsContent.displayName = TabsPrimitive.Content.displayName
75 |
76 | export { Tabs, TabsList, TabsTrigger, TabsContent }
77 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { cn } from '@/lib/utils'
5 | import { Slot } from '@radix-ui/react-slot'
6 | import { cva, type VariantProps } from 'class-variance-authority'
7 | import styles from './Button.module.css'
8 | import { Loader } from './Loader'
9 |
10 | const buttonVariants = cva(styles.base, {
11 | variants: {
12 | variant: {
13 | solid: styles.solid,
14 | outline: styles.outline,
15 | text: styles.text
16 | },
17 | color: {
18 | primary: styles.primary,
19 | secondary: styles.secondary,
20 | error: styles.error,
21 | dark: styles.dark
22 | },
23 | size: {
24 | small: styles.sizeSmall,
25 | medium: styles.sizeMedium,
26 | large: styles.sizeLarge,
27 | icon: styles.sizeIcon
28 | }
29 | },
30 | defaultVariants: {
31 | variant: 'solid',
32 | color: 'primary',
33 | size: 'medium'
34 | }
35 | })
36 |
37 | export type ButtonProps = React.ComponentProps<'button'> &
38 | VariantProps & {
39 | asChild?: boolean
40 | isLoading?: boolean
41 | fullWidth?: boolean
42 | }
43 |
44 | export const Button = React.forwardRef(
45 | (
46 | {
47 | className,
48 | variant,
49 | color,
50 | size,
51 | asChild = false,
52 | isLoading = false,
53 | disabled,
54 | children,
55 | fullWidth = false,
56 | ...props
57 | },
58 | ref
59 | ) => {
60 | const Comp = asChild ? (Slot as React.ElementType) : 'button'
61 | return (
62 |
71 | {asChild ? (
72 | children
73 | ) : (
74 |
75 | {isLoading && (
76 |
77 |
78 |
79 | )}
80 | {children}
81 |
82 | )}
83 |
84 | )
85 | }
86 | )
87 |
88 | Button.displayName = 'Button'
89 |
--------------------------------------------------------------------------------
/dashboard/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from './ui/Button'
2 | import { FormatIcon } from './ui/Icons'
3 | import { Text } from './ui/Text'
4 | import { useEffect, useState } from 'react'
5 | import { motion, AnimatePresence } from 'motion/react'
6 |
7 | interface HeaderProps {
8 | onAskAiClick?: () => void
9 | }
10 |
11 | export const Header = ({ onAskAiClick }: HeaderProps) => {
12 | const [isScrolled, setIsScrolled] = useState(false)
13 | const [showAskAi, setShowAskAi] = useState(false)
14 |
15 | useEffect(() => {
16 | const handleScroll = () => {
17 | const scrollTop = window.scrollY || document.documentElement.scrollTop
18 | setIsScrolled(scrollTop > 48)
19 | setShowAskAi(scrollTop > 96)
20 | }
21 |
22 | window.addEventListener('scroll', handleScroll)
23 | return () => window.removeEventListener('scroll', handleScroll)
24 | }, [])
25 |
26 | return (
27 |
32 |
38 |
39 |
40 |
41 |
42 | tinybird.co
43 |
44 |
45 |
46 |
47 | {showAskAi && (
48 |
54 |
61 |
62 | Ask AI
63 |
64 |
65 | )}
66 |
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## Unreleased
8 | - [Data project] Fix `top_devices` and `top_browsers` session count (#90)
9 |
10 | ## [1.4.0] - 2023-04-12
11 | ### Added
12 | - [Script] Add support to set the domain for the cookie
13 |
14 | ## [1.3.0] - 2023-03-15
15 | ### Added
16 | - [Script] Add support to send attributes set in the script tag
17 |
18 | ## [1.2.3] - 2022-11-06
19 | ### Fixed
20 | - [Dashboard] Fix total KPIs for arbitrary date ranges (#37)
21 | - [Dashboard] Fix tooltip for the last data point in the chart
22 | - [Data project] Fix `analytics_pages` query
23 |
24 | ## [1.2.2] - 2022-09-28
25 | ### Fixed
26 | - [Dashboard] Stop using Google's favicon service (#30)
27 | - [Script] Fix `Content-Type` (#33)
28 |
29 | ## [1.2.1] - 2022-09-27
30 | ### Fixed
31 | - [Script] Remove source maps (#24)
32 | - [Dashboard] Fix UTC date detection (#25)
33 | - [Dashboard] Improve domain guessing
34 | - [Dashboard] Fix chart tooltip
35 | - [Dashboard] Fix date selector width (#23)
36 | - [Dashboard] Fix scroll behaviour (#17)
37 |
38 | ## [1.2.0] - 2022-09-06
39 | ### Changed
40 | - [Dashboard] Optmize bundle size and Core Web Vitals
41 | - [Script] Use `XMLHttpRequest` instead of `sendBeacon`
42 |
43 | ## [1.1.0] - 2022-09-05
44 | ### Changed
45 | - [Script] Improve script listeners, now can be imported as `defer`
46 |
47 | ### Added
48 | - [Dashboard] Add tracking script to dashboard
49 |
50 | ### Fixed
51 | - [Dashboard] Use Tailwind theme colors
52 |
53 | ## [1.0.0] - 2022-09-02
54 | ### Added
55 | - [Data project] Create data project template
56 | - [Middleware] Add Vercel middleware
57 | - [Script] Add tracking script
58 | - [Dashboard] Add dashboard
59 |
60 | [Unreleased]: https://github.com/tinybirdco/web-analytics-starter-kit/compare/1.2.3...HEAD
61 | [1.2.3]: https://github.com/tinybirdco/web-analytics-starter-kit/compare/1.2.2...1.2.3
62 | [1.2.2]: https://github.com/tinybirdco/web-analytics-starter-kit/compare/1.2.1...1.2.2
63 | [1.2.1]: https://github.com/tinybirdco/web-analytics-starter-kit/compare/1.2.0...1.2.1
64 | [1.2.0]: https://github.com/tinybirdco/web-analytics-starter-kit/compare/1.1.0...1.2.0
65 | [1.1.0]: https://github.com/tinybirdco/web-analytics-starter-kit/compare/1.1.0...1.0.0
66 | [1.0.0]: https://github.com/tinybirdco/web-analytics-starter-kit/tree/1.0.0
67 |
--------------------------------------------------------------------------------
/dashboard/components/ui/DomainSelect.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useDomains } from '@/lib/hooks/use-domains'
4 | import useDomain from '@/lib/hooks/use-domain'
5 | import {
6 | SelectContent,
7 | SelectItem,
8 | SelectRoot,
9 | SelectTrigger,
10 | SelectValue
11 | } from './Select'
12 | import { useSearchParams, useRouter } from 'next/navigation'
13 | import { Text } from './Text'
14 |
15 | export function DomainSelect({ className, style }: { className?: string, style?: React.CSSProperties }) {
16 | const { domains, isLoading } = useDomains()
17 | const { domain: fallbackDomain } = useDomain()
18 | const searchParams = useSearchParams()
19 | const router = useRouter()
20 | const domain = searchParams.get('domain') || 'ALL'
21 |
22 | let options = [
23 | { value: 'ALL', label: 'All domains' },
24 | ...(domains?.filter(d => d.domain !== '').map(d => ({ value: d.domain, label: d.domain })) ?? [])
25 | ]
26 |
27 | const handleChange = (newDomain: string) => {
28 | const params = new URLSearchParams(searchParams.toString())
29 | if (newDomain === 'ALL') {
30 | params.delete('domain')
31 | } else {
32 | params.set('domain', newDomain)
33 | }
34 | router.replace(`?${params.toString()}`)
35 | }
36 |
37 | if (isLoading) {
38 | return Loading domains...
39 | }
40 | if (
41 | (!domains || domains.length === 0)
42 | || (domains && domains.length === 1 && domains[0].domain === '')
43 | ) {
44 | if (fallbackDomain && fallbackDomain !== 'domain.com') {
45 | return
46 |
47 | https://{fallbackDomain}
48 |
49 |
50 | }
51 | }
52 |
53 | if (options.length === 1) {
54 | return No domains found
55 | }
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
63 | {options.map(option => (
64 | {option.label}
65 | ))}
66 |
67 |
68 | )
69 | }
--------------------------------------------------------------------------------
/dashboard/app/widgets/top-browsers.tsx:
--------------------------------------------------------------------------------
1 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
2 | import { PipeTable } from '@/components/PipeTable'
3 | import {
4 | TableCellMono,
5 | TableCellBrowser,
6 | TableCellCombined,
7 | TableCellDelta,
8 | } from '@/components/table/TableCells'
9 | import { Card } from '@/components/ui/Card'
10 | import { maxCellWidth } from '@/lib/utils'
11 |
12 | type TopBrowsersData = {
13 | browser: string
14 | visits: number
15 | hits: number
16 | previous_visits?: number
17 | previous_hits?: number
18 | visits_growth_percentage?: number
19 | hits_growth_percentage?: number
20 | }
21 |
22 | export const TopBrowsers = () => {
23 | const { data: topBrowsers } =
24 | useEndpoint('top_browsers', {
25 | include_previous_period: true,
26 | })
27 |
28 | const maxVisitsWidth = maxCellWidth('visits', topBrowsers || [])
29 | const maxHitsWidth = maxCellWidth('hits', topBrowsers || [])
30 |
31 | return (
32 |
33 | ,
41 | },
42 | {
43 | label: 'Visitors',
44 | key: 'visits',
45 | align: 'left',
46 | maxWidth: 80,
47 | render: row => (
48 |
49 | {row.visits.toLocaleString()}
50 | {row.visits_growth_percentage !== undefined && (
51 |
54 | )}
55 |
56 | ),
57 | },
58 | {
59 | label: 'Views',
60 | key: 'hits',
61 | align: 'left',
62 | maxWidth: 80,
63 | render: row => (
64 |
65 | {row.hits.toLocaleString()}
66 | {row.hits_growth_percentage !== undefined && (
67 |
70 | )}
71 |
72 | ),
73 | },
74 | ]}
75 | />
76 |
77 | )
78 | }
--------------------------------------------------------------------------------
/dashboard/app/widgets/top-devices.tsx:
--------------------------------------------------------------------------------
1 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
2 | import { PipeTable } from '@/components/PipeTable'
3 | import {
4 | TableCellMono,
5 | TableCellDevice,
6 | TableCellCombined,
7 | TableCellDelta,
8 | } from '@/components/table/TableCells'
9 | import { Card } from '@/components/ui/Card'
10 | import { maxCellWidth } from '@/lib/utils'
11 |
12 | type TopDevicesData = {
13 | device: string
14 | visits: number
15 | hits: number
16 | previous_visits?: number
17 | previous_hits?: number
18 | visits_growth_percentage?: number
19 | hits_growth_percentage?: number
20 | }
21 |
22 | export const TopDevices = () => {
23 | const { data: topDevices } =
24 | useEndpoint('top_devices', {
25 | include_previous_period: true,
26 | })
27 |
28 | const maxVisitsWidth = maxCellWidth('visits', topDevices || [])
29 | const maxHitsWidth = maxCellWidth('hits', topDevices || [])
30 |
31 | return (
32 |
33 | ,
41 | },
42 | {
43 | label: 'Visitors',
44 | key: 'visits',
45 | align: 'left',
46 | maxWidth: 80,
47 | render: row => (
48 |
49 |
50 | {row.visits?.toLocaleString?.()}
51 |
52 | {row.visits_growth_percentage !== undefined && (
53 |
56 | )}
57 |
58 | ),
59 | },
60 | {
61 | label: 'Views',
62 | key: 'hits',
63 | align: 'left',
64 | maxWidth: 80,
65 | render: row => (
66 |
67 | {row.hits.toLocaleString()}
68 | {row.hits_growth_percentage !== undefined && (
69 |
72 | )}
73 |
74 | ),
75 | },
76 | ]}
77 | />
78 |
79 | )
80 | }
--------------------------------------------------------------------------------
/dashboard/lib/api.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'cross-fetch'
2 | import {
3 | ClientResponse,
4 | PipeParams,
5 | QueryPipe,
6 | QuerySQL,
7 | QueryError,
8 | } from './types/api'
9 | import config from './config'
10 |
11 | export function getConfig(search?: string) {
12 | // Check if we're in the browser
13 | const isBrowser = typeof window !== 'undefined'
14 |
15 | // Use provided search param, or window.location.search if in browser, or empty string if on server
16 | const searchParams = new URLSearchParams(search || (isBrowser ? window.location.search : ''))
17 |
18 | const token = config.authToken ?? searchParams?.get('token')
19 | const host = config.host ?? searchParams?.get('host')
20 |
21 | return {
22 | token,
23 | host,
24 | }
25 | }
26 |
27 | export async function client(
28 | path: string,
29 | params?: RequestInit
30 | ): Promise> {
31 | const { host, token } = getConfig()
32 |
33 | if (!token || !host) throw new Error('Configuration not found')
34 |
35 | const apiUrl =
36 | {
37 | 'https://ui.tinybird.co': 'https://api.tinybird.co',
38 | 'https://ui.us-east.tinybird.co': 'https://api.us-east.tinybird.co',
39 | }[host] ?? host
40 |
41 | const response = await fetch(`${apiUrl}/v0${path}`, {
42 | headers: {
43 | Authorization: `Bearer ${token}`,
44 | },
45 | ...params,
46 | })
47 | const data = (await response.json()) as ClientResponse
48 |
49 | if (!response.ok) {
50 | throw new QueryError(data?.error ?? 'Something went wrong', response.status)
51 | }
52 |
53 | return data
54 | }
55 |
56 | export async function fetcher(
57 | path: string,
58 | params?: RequestInit
59 | ): Promise> {
60 | const { host, token } = getConfig()
61 |
62 | if (!token || !host) throw new Error('Configuration not found')
63 |
64 | const response = await fetch(path, {
65 | headers: {
66 | Authorization: `Bearer ${token}`,
67 | },
68 | ...params,
69 | })
70 | const data = (await response.json()) as ClientResponse
71 |
72 | if (!response.ok) {
73 | throw new QueryError(data?.error ?? 'Something went wrong', response.status)
74 | }
75 |
76 | return data
77 | }
78 |
79 | export function queryPipe(
80 | name: string,
81 | params: Partial> = {}
82 | ): Promise> {
83 | const searchParams = new URLSearchParams()
84 | Object.entries(params).forEach(([key, value]) => {
85 | if (!value) return
86 | searchParams.set(key, value as string)
87 | })
88 |
89 | return client(`/pipes/${name}.json?${searchParams}`)
90 | }
91 |
92 | export function querySQL(sql: string): Promise> {
93 | return client(`/sql?q=${sql}`)
94 | }
95 |
--------------------------------------------------------------------------------
/dashboard/app/widgets/top-pages.tsx:
--------------------------------------------------------------------------------
1 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
2 | import { PipeTable } from '@/components/PipeTable'
3 | import {
4 | TableCellText,
5 | TableCellMono,
6 | TableCellCombined,
7 | TableCellProgress,
8 | TableCellDelta,
9 | } from '@/components/table/TableCells'
10 | import { Card } from '@/components/ui/Card'
11 | import { maxCellWidth } from '@/lib/utils'
12 |
13 | type TopPagesData = {
14 | pathname: string
15 | visits: number
16 | hits: number
17 | previous_visits?: number
18 | previous_hits?: number
19 | visits_growth_percentage?: number
20 | hits_growth_percentage?: number
21 | }
22 |
23 | export const TopPages = () => {
24 | const { data: topPages } = useEndpoint('top_pages', {
25 | include_previous_period: true,
26 | })
27 |
28 | const maxVisitsWidth = maxCellWidth('visits', topPages || [])
29 | const maxHitsWidth = maxCellWidth('hits', topPages || [])
30 |
31 | return (
32 |
33 | {row.pathname} ,
41 | },
42 | {
43 | label: 'Visitors',
44 | key: 'visits',
45 | align: 'left',
46 | maxWidth: 128,
47 | render: row => {
48 | const maxVisits = Math.max(...(topPages || []).map(p => p.visits))
49 | return (
50 |
51 |
52 | {row.visits.toLocaleString()}
53 | {row.visits_growth_percentage !== undefined && (
54 |
57 | )}
58 |
59 | )
60 | },
61 | },
62 | {
63 | label: 'Views',
64 | key: 'hits',
65 | align: 'left',
66 | maxWidth: 80,
67 | render: row => (
68 |
69 | {row.hits.toLocaleString()}
70 | {row.hits_growth_percentage !== undefined && (
71 |
74 | )}
75 |
76 | ),
77 | },
78 | ]}
79 | />
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Tabs.module.css:
--------------------------------------------------------------------------------
1 | .tabs {
2 | width: 100%;
3 | }
4 |
5 | .list {
6 | display: flex;
7 | align-items: center;
8 | gap: 32px;
9 | border-bottom: 1px solid #e5e7eb;
10 | padding-bottom: 8px;
11 | margin-bottom: 16px;
12 | }
13 |
14 | .trigger {
15 | display: inline-flex;
16 | align-items: center;
17 | justify-content: center;
18 | white-space: nowrap;
19 | padding: 8px 0;
20 | font-size: 14px;
21 | font-weight: 500;
22 | color: #6b7280;
23 | background: none;
24 | border: none;
25 | cursor: pointer;
26 | transition: color 0.2s ease;
27 | position: relative;
28 | user-select: none;
29 | }
30 |
31 | .trigger:focus-visible {
32 | outline: none;
33 | box-shadow: 0 0 0 2px var(--border-03-color);
34 | }
35 |
36 | .trigger:disabled {
37 | pointer-events: none;
38 | opacity: 0.5;
39 | }
40 |
41 | .trigger[data-state='active'] {
42 | color: #111827;
43 | }
44 |
45 | .trigger[data-state='active']::after {
46 | content: '';
47 | position: absolute;
48 | bottom: -9px;
49 | left: 0;
50 | right: 0;
51 | height: 2px;
52 | background-color: #374151;
53 | border-radius: 1px;
54 | }
55 |
56 | .content {
57 | width: 100%;
58 | }
59 |
60 | .content:focus-visible {
61 | outline: none;
62 | box-shadow: 0 0 0 2px var(--border-03-color);
63 | }
64 |
65 | /* Pill variant - restores previous styles */
66 | .pill .list {
67 | display: inline-flex;
68 | height: 24px;
69 | align-items: center;
70 | color: var(--text-01-color);
71 | gap: 4px;
72 | border-bottom: none;
73 | padding-bottom: 0;
74 | margin-bottom: 0;
75 | }
76 |
77 | .pill .trigger {
78 | display: inline-flex;
79 | align-items: center;
80 | justify-content: center;
81 | white-space: nowrap;
82 | border-radius: 4px;
83 | padding: 4px 10px;
84 | height: 32px;
85 | font: var(--font-body);
86 | font-weight: 500;
87 | color: var(--text-02-color);
88 | transition: all 0.2s;
89 | background: none;
90 | border: none;
91 | cursor: pointer;
92 | position: relative;
93 | user-select: none;
94 | }
95 |
96 | .pill .trigger[data-state='active'] {
97 | color: var(--text-color);
98 | border: 1px solid var(--border-02-color);
99 | }
100 |
101 | .pill .trigger[data-state='active']::after {
102 | display: none;
103 | }
104 |
105 | .dark .trigger {
106 | color: var(--text-02-color);
107 | }
108 |
109 | .dark .trigger[data-state='active'] {
110 | background-color: var(--background-dark-01-color);
111 | color: var(--text-inverse-color);
112 | }
113 |
114 | .dark.pill .trigger[data-state='active'] {
115 | background-color: var(--background-dark-01-color);
116 | color: var(--text-inverse-color);
117 | }
118 |
119 | .code .trigger {
120 | font: var(--font-code);
121 | }
122 |
--------------------------------------------------------------------------------
/dashboard/app/widgets/top-sources.tsx:
--------------------------------------------------------------------------------
1 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
2 | import { PipeTable } from '@/components/PipeTable'
3 | import {
4 | TableCellText,
5 | TableCellMono,
6 | TableCellCombined,
7 | TableCellProgress,
8 | TableCellDelta,
9 | } from '@/components/table/TableCells'
10 | import { Card } from '@/components/ui/Card'
11 | import { maxCellWidth } from '@/lib/utils'
12 |
13 | type TopSourcesData = {
14 | referrer: string
15 | visits: number
16 | hits: number
17 | previous_visits?: number
18 | previous_hits?: number
19 | visits_growth_percentage?: number
20 | hits_growth_percentage?: number
21 | }
22 |
23 | export const TopSources = () => {
24 | const { data: topSources } =
25 | useEndpoint('top_sources', {
26 | include_previous_period: true,
27 | })
28 |
29 | const maxVisitsWidth = maxCellWidth('visits', topSources || [])
30 | const maxHitsWidth = maxCellWidth('hits', topSources || [])
31 |
32 | return (
33 |
34 | {row.referrer} ,
42 | },
43 | {
44 | label: 'Visitors',
45 | key: 'visits',
46 | align: 'left',
47 | maxWidth: 128,
48 | render: row => {
49 | const maxVisits = Math.max(...(topSources || []).map(p => p.visits))
50 | return (
51 |
52 |
53 | {row.visits.toLocaleString()}
54 | {row.visits_growth_percentage !== undefined && (
55 |
58 | )}
59 |
60 | )
61 | },
62 | },
63 | {
64 | label: 'Views',
65 | key: 'hits',
66 | align: 'left',
67 | maxWidth: 80,
68 | render: row => (
69 |
70 | {row.hits.toLocaleString()}
71 | {row.hits_growth_percentage !== undefined && (
72 |
75 | )}
76 |
77 | ),
78 | },
79 | ]}
80 | />
81 |
82 | )
83 | }
--------------------------------------------------------------------------------
/dashboard/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tinybirdco/analytics-dashboard",
3 | "version": "1.4.0",
4 | "description": "Tinybird web analytics dashboard",
5 | "homepage": "https://github.com/tinybirdco/web-analytics-starter-kit/tree/main/dashboard#README",
6 | "bugs": {
7 | "url": "https://github.com/tinybirdco/web-analytics-starter-kit/issues"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/tinybirdco/web-analytics-starter-kit"
12 | },
13 | "license": "MIT",
14 | "author": "Tinybird team",
15 | "scripts": {
16 | "dev": "next dev",
17 | "build": "next build",
18 | "start": "next start",
19 | "lint": "next lint"
20 | },
21 | "dependencies": {
22 | "@ai-sdk/google-vertex": "^2.2.27",
23 | "@ai-sdk/openai": "^1.3.22",
24 | "@ai-sdk/react": "^1.2.12",
25 | "@date-fns/tz": "^1.2.0",
26 | "@date-fns/utc": "^2.1.0",
27 | "@modelcontextprotocol/sdk": "^1.12.0",
28 | "@radix-ui/react-checkbox": "^1.1.3",
29 | "@radix-ui/react-collapsible": "1.0.3",
30 | "@radix-ui/react-context-menu": "^2.2.5",
31 | "@radix-ui/react-dialog": "1.1.4",
32 | "@radix-ui/react-dropdown-menu": "^2.1.5",
33 | "@radix-ui/react-popover": "1.1.4",
34 | "@radix-ui/react-radio-group": "1.1.3",
35 | "@radix-ui/react-select": "^2.1.5",
36 | "@radix-ui/react-slider": "^1.2.1",
37 | "@radix-ui/react-slot": "1.1.1",
38 | "@radix-ui/react-tabs": "^1.1.2",
39 | "@radix-ui/react-toast": "^1.2.5",
40 | "@radix-ui/react-tooltip": "^1.1.7",
41 | "ai": "^4.3.17",
42 | "class-variance-authority": "^0.7.1",
43 | "country-flag-icons": "^1.5.19",
44 | "date-fns": "^4.1.0",
45 | "dompurify": "3.2.6",
46 | "jose": "^6.0.12",
47 | "lucide-react": "^0.525.0",
48 | "motion": "^12.23.3",
49 | "next": "14.2.35",
50 | "numeral": "^2.0.6",
51 | "react": "^18.2.0",
52 | "react-contenteditable": "^3.3.7",
53 | "react-dom": "^18.2.0",
54 | "react-intersection-observer": "^9.4.0",
55 | "react-markdown": "^10.1.0",
56 | "recharts": "^3.0.2",
57 | "swr": "^2.2.5"
58 | },
59 | "devDependencies": {
60 | "@types/node": "^22.13.10",
61 | "@types/numeral": "^2.0.5",
62 | "@types/react": "^18.0.17",
63 | "@types/react-dom": "^18.0.6",
64 | "@vitejs/plugin-react": "^2.0.1",
65 | "autoprefixer": "^10.4.8",
66 | "cross-fetch": "^3.1.5",
67 | "eslint": "^8.22.0",
68 | "eslint-config-next": "12.2.5",
69 | "eslint-config-prettier": "^8.5.0",
70 | "eslint-plugin-prettier": "^4.2.1",
71 | "jsdom": "^20.0.0",
72 | "msw": "^2.6.6",
73 | "postcss": "^8.4.16",
74 | "prettier": "^2.7.1",
75 | "start-server-and-test": "^2.0.10",
76 | "tailwindcss": "^3.1.8",
77 | "typescript": "5.7.3"
78 | },
79 | "engines": {
80 | "node": "^22.0.0"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/tinybird/README.md:
--------------------------------------------------------------------------------
1 | # Tinybird Data Project
2 |
3 | ## Project structure
4 |
5 | ```
6 | web-analytics-starter-kit/tinybird/
7 | ├── datasources
8 | │ ├── analytics_events.datasource
9 | │ ├── analytics_pages_mv.datasource
10 | │ ├── analytics_sessions_mv.datasource
11 | │ └── analytics_sources_mv.datasource
12 | ├── endpoints
13 | │ ├── analytics_hits.pipe
14 | │ ├── current_visitors.pipe
15 | │ ├── domain.pipe
16 | │ ├── kpis.pipe
17 | │ ├── top_browsers.pipe
18 | │ ├── top_devices.pipe
19 | │ ├── top_locations.pipe
20 | │ ├── top_pages.pipe
21 | │ ├── top_sources.pipe
22 | │ └── trend.pipe
23 | ├── materializations
24 | │ ├── analytics_pages.pipe
25 | │ ├── analytics_sessions.pipe
26 | │ └── analytics_sources.pipe
27 | ├── web_vitals
28 | │ ├── web_vitals_current.pipe
29 | │ ├── web_vitals_distribution.pipe
30 | │ └── web_vitals_routes.pipe
31 | ├── fixtures
32 | │ ├── analytics_events.ndjson
33 | │ └── analytics_events.sql
34 | ├── tests
35 | ├── .gitignore
36 | ├── .cursorrules
37 | ├── CLAUDE.md
38 | └── README.md
39 | ```
40 |
41 | ### Folder descriptions
42 |
43 | - **datasources/**: Contains all datasource definitions, including the main analytics_events datasource and materialized view datasources.
44 | - **endpoints/**: Contains all API pipes/endpoints for web analytics, such as analytics_hits, kpis, top_browsers, top_devices, top_locations, top_pages, top_sources, trend, current_visitors, and domain.
45 | - **materializations/**: Contains materialized view pipes for analytics_pages, analytics_sessions, and analytics_sources.
46 | - **web_vitals/**: Contains API pipes/endpoints for web vitals metrics.
47 | - **tests/**: Contains tests.
48 | - **fixtures/**: Contains data and SQL for analytics_events.
49 | - **.gitignore, .cursorrules, CLAUDE.md, README.md**: Project configuration and documentation files.
50 |
51 | ## Project description
52 |
53 | The Tinybird data project for web analytics includes datasources, endpoints, and materializations to power analytics dashboards and APIs. The main datasource, `analytics_events`, collects events from the tracker script. Endpoints provide parsed and aggregated analytics, and materializations enable efficient querying for dashboards.
54 |
55 | `web_vitals` metrics are stored in `analytics_events` with `action=web_vital`. See `web_vitals` folder for example endpoints.
56 |
57 | ## Local development
58 |
59 | ```bash
60 | # install the tinybird CLI
61 | curl https://tinybird.co | sh
62 |
63 | tb local start
64 |
65 | # select or create a new workspace
66 | tb login
67 |
68 | tb dev
69 | tb token ls # copy the local admin token
70 | ```
71 |
72 | Use `http://localhost:7181` as NEXT_PUBLIC_TINYBIRD_HOST and the admin token in the [dashboard](../dashboard/README.md).
73 |
74 | ## Cloud deployment
75 |
76 | After validating your changes use `tb --cloud deploy`
77 |
--------------------------------------------------------------------------------
/dashboard/app/widgets/top-locations.tsx:
--------------------------------------------------------------------------------
1 | import { useEndpoint } from '@/lib/hooks/use-endpoint'
2 | import { PipeTable } from '@/components/PipeTable'
3 | import {
4 | TableCellMono,
5 | TableCellCountry,
6 | TableCellCombined,
7 | TableCellProgress,
8 | TableCellDelta,
9 | } from '@/components/table/TableCells'
10 | import { Card } from '@/components/ui/Card'
11 | import { maxCellWidth } from '@/lib/utils'
12 |
13 | type TopLocationsData = {
14 | location: string
15 | visits: number
16 | hits: number
17 | previous_visits?: number
18 | previous_hits?: number
19 | visits_growth_percentage?: number
20 | hits_growth_percentage?: number
21 | }
22 |
23 | export const TopLocations = () => {
24 | const { data: topLocations } =
25 | useEndpoint('top_locations', {
26 | include_previous_period: true,
27 | })
28 |
29 | const maxVisitsWidth = maxCellWidth('visits', topLocations || [])
30 | const maxHitsWidth = maxCellWidth('hits', topLocations || [])
31 |
32 | return (
33 |
34 | ,
42 | },
43 | {
44 | label: 'Visitors',
45 | key: 'visits',
46 | align: 'left',
47 | maxWidth: 128,
48 | render: row => {
49 | const maxVisits = Math.max(...(topLocations || []).map(p => p.visits))
50 | return (
51 |
52 |
53 | {row.visits.toLocaleString()}
54 | {row.visits_growth_percentage !== undefined && (
55 |
58 | )}
59 |
60 | )
61 | },
62 | },
63 | {
64 | label: 'Views',
65 | key: 'hits',
66 | align: 'left',
67 | maxWidth: 80,
68 | render: row => (
69 |
70 | {row.hits.toLocaleString()}
71 | {row.hits_growth_percentage !== undefined && (
72 |
75 | )}
76 |
77 | ),
78 | },
79 | ]}
80 | />
81 |
82 | )
83 | }
--------------------------------------------------------------------------------
/dashboard/README.md:
--------------------------------------------------------------------------------
1 | # Dashboard Starter Kit
2 |
3 | This is a tool to consume the pipes and visualize all the events that has been sent to your data project.
4 |
5 | ## Tech stack
6 |
7 | To build this Starter Kit template we have used:
8 |
9 | - [Next.js](https://nextjs.org/) with [React](https://reactjs.org/) v18 as a framework
10 | - [Vercel](https://vercel.com/) as deployment system
11 | - [Tailwind](https://tailwindcss.com/) with theme configuration for CSS styling
12 | - [SWR](https://swr.vercel.app/es-ES) for data fetching
13 |
14 | ## How to use it?
15 |
16 | ### Install
17 |
18 | First of all, you have to clone the repo if you haven't already
19 |
20 | ```bash
21 | $ git clone git@github.com:tinybirdco/web-analytics-starter-kit.git
22 | ```
23 |
24 | Then navigate into the `/dashboard` folder and install the dependencies
25 |
26 | ```bash
27 | $ cd web-analytics-starter-kit/dashboard
28 | $ npm install
29 | ```
30 |
31 | ### Build for Development
32 |
33 | Once you have installed the dependencies, run:
34 |
35 | ```bash
36 | npm run dev
37 | ```
38 |
39 | You will find the app running at http://localhost:3000
40 |
41 | Copy the .env.example file and rename it to .env.
42 |
43 | ```
44 | NEXT_PUBLIC_ASK_TINYBIRD_ENDPOINT="https://ask-tb.tinybird.live/api/chat" # To use the Ask AI functionality
45 |
46 | NEXT_PUBLIC_TINYBIRD_DASHBOARD_URL=http://localhost:3000
47 | NEXT_PUBLIC_TINYBIRD_TRACKER_TOKEN=
48 | NEXT_PUBLIC_TINYBIRD_AUTH_TOKEN=
49 | NEXT_PUBLIC_TINYBIRD_HOST=
50 | ```
51 |
52 | To develop locally, start [Tinybird Local](https://www.tinybird.co/docs/cli/local-container) and use `http://localhost` as NEXT_PUBLIC_TINYBIRD_HOST.
53 |
54 | ### Build for Production
55 |
56 | ```bash
57 | npm run build
58 | ```
59 |
60 | ```bash
61 | npm run start
62 | ```
63 |
64 | And you will find the app running at but with the production bundle http://localhost:3000
65 |
66 | ### Deployment
67 |
68 | Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).
69 |
70 | ## Contributing
71 |
72 | Contributions are always welcome. To contribute, commit your changes into a new branch, and open a pull request against the main branch.
73 |
74 | Please be careful in describing the problem and the implemented solution so that we can make the best review possible.
75 |
76 | ### Issues
77 |
78 | Also, you can [open an issue](https://github.com/tinybirdco/web-analytics-starter-kit/issues) if you've encountered a bug or an enhancement on the Dashboard.
79 |
80 | ## Customization
81 |
82 | We encourage you to [fork](https://docs.github.com/es/get-started/quickstart/fork-a-repo) the repo and customize the dashboard adapting it to your needs and to your branding image.
83 |
84 | ## Licence
85 |
86 | MIT License
87 |
88 | Copyright (c) 2022 Tinybird.co
89 |
90 | Permission is hereby granted, free of charge, to any person obtaining a copy
91 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/AIChatToolCall.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { SqlChart } from '@/components/ui/SqlChart'
5 | import { PipeTable } from '@/components/PipeTable'
6 | import { CoreVitalGauge } from '@/components/ui/CoreVitalGauge'
7 | import { Loader } from '@/components/ui/Loader'
8 | import { CheckIcon } from '@/components/ui/Icons'
9 | import { Text } from '@/components/ui/Text'
10 |
11 | interface AIChatToolCallProps {
12 | part: any
13 | partIndex: number
14 | isResult?: boolean
15 | status: string
16 | }
17 |
18 | export function AIChatToolCall({
19 | part,
20 | isResult = false,
21 | status,
22 | }: AIChatToolCallProps) {
23 | const getToolLabel = (tool: string) => {
24 | switch (tool) {
25 | case 'explore_data':
26 | return 'Exploring your data'
27 | case 'list_endpoints':
28 | return 'Scanning your datasources'
29 | case 'list_datasources':
30 | return 'Scanning your endpoints'
31 | case 'list_service_datasources':
32 | return 'Scanning your datasources'
33 | case 'text_to_sql':
34 | return 'Running SQL query'
35 | case 'execute_query':
36 | return 'Running SQL query'
37 | default:
38 | return `Querying your data`
39 | }
40 | }
41 |
42 | if (part.type === 'tool-invocation') {
43 | const toolName = part.toolInvocation.toolName
44 | const result = part.toolInvocation?.result
45 |
46 | if (toolName === 'renderSqlChart' && result) {
47 | return (
48 |
59 | )
60 | }
61 |
62 | if (toolName === 'renderPipeTable' && result) {
63 | return (
64 |
73 | )
74 | }
75 |
76 | if (toolName === 'renderCoreVitalGauge' && result) {
77 | return (
78 |
83 | )
84 | }
85 |
86 | return (
87 |
88 | {status !== 'ready' ? (
89 |
90 | ) : (
91 |
92 | )}
93 |
94 | {getToolLabel(toolName)}
95 |
96 |
97 | )
98 | }
99 |
100 | return null
101 | }
102 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DialogPrimitive from '@radix-ui/react-dialog'
5 | import styles from './Dialog.module.css'
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => {
19 | return (
20 |
25 | )
26 | })
27 |
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => {
34 | return (
35 |
36 |
37 |
42 | {children}
43 |
44 |
45 | )
46 | })
47 |
48 | DialogContent.displayName = DialogPrimitive.Content.displayName
49 |
50 | function DialogHeader({
51 | className,
52 | ...props
53 | }: React.HTMLAttributes) {
54 | return
55 | }
56 |
57 | function DialogFooter({
58 | className,
59 | ...props
60 | }: React.HTMLAttributes) {
61 | return
62 | }
63 |
64 | const DialogTitle = React.forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, ...props }, ref) => {
68 | return (
69 |
74 | )
75 | })
76 |
77 | DialogTitle.displayName = DialogPrimitive.Title.displayName
78 |
79 | const DialogDescription = React.forwardRef<
80 | React.ElementRef,
81 | React.ComponentPropsWithoutRef
82 | >(({ className, ...props }, ref) => {
83 | return (
84 |
89 | )
90 | })
91 |
92 | DialogDescription.displayName = DialogPrimitive.Description.displayName
93 |
94 | export {
95 | Dialog,
96 | DialogPortal,
97 | DialogOverlay,
98 | DialogTrigger,
99 | DialogClose,
100 | DialogContent,
101 | DialogHeader,
102 | DialogFooter,
103 | DialogTitle,
104 | DialogDescription
105 | }
106 |
--------------------------------------------------------------------------------
/dashboard/components/table/TableCells.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from '../ui/Text'
2 | import { Link } from '../ui/Link'
3 | import { cn } from '@/lib/utils'
4 | import styles from './TableCells.module.css'
5 | import * as Flags from 'country-flag-icons/react/3x2'
6 | import countries from '@/lib/constants/countries'
7 | import browsers from '@/lib/constants/browsers'
8 | import devices from '@/lib/constants/devices'
9 |
10 | // Regular text cell
11 | export function TableCellText({ children }: { children: React.ReactNode }) {
12 | return {children}
13 | }
14 |
15 | // Bold text cell, optionally as a link
16 | export function TableCellBold({ children, href }: { children: React.ReactNode; href?: string }) {
17 | if (href) {
18 | return {children}
19 | }
20 | return {children}
21 | }
22 |
23 | // Monospaced number cell
24 | export function TableCellMono({ children, width }: { children: React.ReactNode, width?: number }) {
25 | return {children}
26 | }
27 |
28 | // Progress bar cell
29 | export function TableCellProgress({ value, max }: { value: number; max: number }) {
30 | const percent = Math.max(0, Math.min(100, (value / max) * 100))
31 | return (
32 |
37 | )
38 | }
39 |
40 | // Delta indicator cell
41 | export function TableCellDelta({ delta }: { delta: number }) {
42 | const isPositive = delta > 0
43 | const isNegative = delta < 0
44 | const color = isPositive ? styles.deltaPositive : isNegative ? styles.deltaNegative : styles.deltaNeutral
45 | const sign = isPositive ? '+' : ''
46 | return (
47 |
48 | {sign}{delta?.toLocaleString()}%
49 |
50 | )
51 | }
52 |
53 | // Combined cell for layouts like number + progress, number + delta, etc.
54 | export function TableCellCombined({ children }: { children: React.ReactNode }) {
55 | return {children}
56 | }
57 |
58 | export function TableCellCountry({ code }: { code: string }) {
59 | const countryCode = code?.toUpperCase?.() || ''
60 | const name = countries[countryCode as keyof typeof countries] || countryCode || '(unknown)'
61 | const Flag = (Flags as any)[countryCode]
62 | return (
63 |
64 | {Flag ? (
65 |
66 |
67 |
68 | ) : null}
69 | {name}
70 |
71 | )
72 | }
73 |
74 | export function TableCellBrowser({ code }: { code: string }) {
75 | const name = browsers[code as keyof typeof browsers] || code || '(unknown)'
76 | return {name}
77 | }
78 |
79 | export function TableCellDevice({ code }: { code: string }) {
80 | const name = devices[code as keyof typeof devices] || code || '(unknown)'
81 | return {name}
82 | }
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 |
3 | ### Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ### Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | - Trolling, insulting/derogatory comments, and personal or political attacks
28 | - Public or private harassment
29 | - Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | - Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ### Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ### Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ### Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at [hi@tinybird.co](mailto:hi@tinybird.co). All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ### Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Input.module.css:
--------------------------------------------------------------------------------
1 | .input {
2 | display: flex;
3 | align-items: center;
4 | height: 40px;
5 | width: 100%;
6 | border-radius: 4px;
7 | border: 1px solid transparent;
8 | background-color: var(--neutral-00-color);
9 | transition: color 200ms ease;
10 | padding: 12px;
11 | font-size: 16px;
12 | outline: none;
13 | background: var(--neutral-00-color);
14 | border: 1px solid var(--border-02-color);
15 | border-radius: 4px;
16 | font: var(--font-body);
17 | width: 100%;
18 | outline: 1px solid transparent;
19 | outline-offset: -2px;
20 | }
21 |
22 | .input::file-selector-button {
23 | border: 0;
24 | background-color: transparent;
25 | font-size: 14px;
26 | font-weight: 500;
27 | color: var(--text-color);
28 | }
29 |
30 | .input::placeholder {
31 | color: var(--text-01-color);
32 | }
33 |
34 | .input:hover:not(:disabled) {
35 | outline: 2px solid var(--border-03-color);
36 | }
37 |
38 | .input:focus-visible {
39 | outline: none;
40 | }
41 |
42 | .input:focus-within {
43 | outline: 2px solid var(--border-03-color);
44 | }
45 |
46 | .input:disabled {
47 | cursor: not-allowed;
48 | opacity: 0.5;
49 | }
50 |
51 | /* InputOTP Styles */
52 | .inputOtpContainer {
53 | display: flex;
54 | align-items: center;
55 | gap: 8px;
56 | }
57 |
58 | .inputOtpContainer:has([disabled]) {
59 | opacity: 0.5;
60 | }
61 |
62 | .inputOtp {
63 | transition: all 200ms ease;
64 | }
65 |
66 | .inputOtp:disabled {
67 | cursor: not-allowed;
68 | }
69 |
70 | .inputOtpGroup {
71 | display: flex;
72 | align-items: center;
73 | }
74 |
75 | .inputOtpSlot {
76 | position: relative;
77 | display: flex;
78 | height: 36px;
79 | width: 36px;
80 | align-items: center;
81 | justify-content: center;
82 | border: 1px solid var(--border-02-color);
83 | font-size: 14px;
84 | transition: all 200ms ease;
85 | outline: 1px solid transparent;
86 | outline-offset: -2px;
87 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
88 | background-color: var(--neutral-00-color);
89 | }
90 |
91 | .inputOtpSlot:first-child {
92 | border-top-left-radius: 6px;
93 | border-bottom-left-radius: 6px;
94 | }
95 |
96 | .inputOtpSlot:last-child {
97 | border-top-right-radius: 6px;
98 | border-bottom-right-radius: 6px;
99 | }
100 |
101 | .inputOtpSlot:not(:first-child) {
102 | border-left: none;
103 | }
104 |
105 | .inputOtpSlot:hover:not([disabled]) {
106 | outline: 2px solid var(--border-03-color);
107 | }
108 |
109 | .inputOtpSlot[data-active='true'] {
110 | z-index: 10;
111 | outline: 2px solid var(--border-03-color);
112 | }
113 |
114 | .inputOtpSlot[aria-invalid='true'] {
115 | border-color: var(--error-color);
116 | }
117 |
118 | .inputOtpSlot[data-active='true'][aria-invalid='true'] {
119 | outline-color: var(--error-color);
120 | }
121 |
122 | .inputOtpCaretContainer {
123 | pointer-events: none;
124 | position: absolute;
125 | inset: 0;
126 | display: flex;
127 | align-items: center;
128 | justify-content: center;
129 | }
130 |
131 | .inputOtpCaret {
132 | height: 16px;
133 | width: 1px;
134 | background-color: var(--text-color);
135 | animation: caretBlink 1s infinite;
136 | }
137 |
138 | @keyframes caretBlink {
139 | 0%,
140 | 50% {
141 | opacity: 1;
142 | }
143 | 51%,
144 | 100% {
145 | opacity: 0;
146 | }
147 | }
148 |
149 | @media (min-width: 768px) {
150 | .input {
151 | font-size: 14px;
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/dashboard/lib/hooks/use-time-range.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo } from 'react'
2 | import { useSearchParams, useRouter } from 'next/navigation'
3 | import { format, subDays, subMonths } from 'date-fns'
4 |
5 | export type TimeRangeOption = {
6 | label: string
7 | value: string
8 | period: string
9 | getRange: () => { date_from: string; date_to: string }
10 | }
11 |
12 | const today = new Date()
13 |
14 | export const timeRanges: TimeRangeOption[] = [
15 | {
16 | label: 'Today',
17 | value: 'today',
18 | period: 'day',
19 | getRange: () => ({
20 | date_from: format(today, 'yyyy-MM-dd'),
21 | date_to: format(today, 'yyyy-MM-dd'),
22 | }),
23 | },
24 | {
25 | label: 'Yesterday',
26 | value: 'yesterday',
27 | period: 'day',
28 | getRange: () => {
29 | const y = subDays(today, 1)
30 | return {
31 | date_from: format(y, 'yyyy-MM-dd'),
32 | date_to: format(y, 'yyyy-MM-dd'),
33 | }
34 | },
35 | },
36 | {
37 | label: '7 days',
38 | value: '7d',
39 | period: 'week',
40 | getRange: () => ({
41 | date_from: format(subDays(today, 6), 'yyyy-MM-dd'),
42 | date_to: format(today, 'yyyy-MM-dd'),
43 | }),
44 | },
45 | {
46 | label: '30 days',
47 | value: '30d',
48 | period: 'month',
49 | getRange: () => ({
50 | date_from: format(subDays(today, 29), 'yyyy-MM-dd'),
51 | date_to: format(today, 'yyyy-MM-dd'),
52 | }),
53 | },
54 | {
55 | label: '12 months',
56 | value: '12m',
57 | period: 'year',
58 | getRange: () => ({
59 | date_from: format(subMonths(today, 12), 'yyyy-MM-dd'),
60 | date_to: format(today, 'yyyy-MM-dd'),
61 | }),
62 | },
63 | ]
64 |
65 | export function useTimeRange() {
66 | const searchParams = useSearchParams()
67 | const router = useRouter()
68 |
69 | // Find current value from search params or default to '7d'
70 | const value = useMemo(() => {
71 | const date_from = searchParams.get('date_from')
72 | const date_to = searchParams.get('date_to')
73 | // Try to match a range
74 | const found = timeRanges.find(opt => {
75 | const { date_from: df, date_to: dt } = opt.getRange()
76 | return df === date_from && dt === date_to
77 | })
78 | return found?.value || '7d'
79 | }, [searchParams])
80 |
81 | // Set value and update search params
82 | const setValue = useCallback(
83 | (newValue: string) => {
84 | const option = timeRanges.find(opt => opt.value === newValue) || timeRanges[2] // default 7d
85 | const { date_from, date_to } = option.getRange()
86 | const params = new URLSearchParams(searchParams.toString())
87 | params.set('date_from', date_from)
88 | params.set('date_to', date_to)
89 | router.replace(`?${params.toString()}`)
90 | },
91 | [router, searchParams]
92 | )
93 |
94 | // Always ensure search params are set on mount
95 | useEffect(() => {
96 | const date_from = searchParams?.get('date_from')
97 | const date_to = searchParams?.get('date_to')
98 | if (!date_from || !date_to) {
99 | setValue('7d')
100 | }
101 | // eslint-disable-next-line react-hooks/exhaustive-deps
102 | }, [])
103 |
104 | const currentOption = timeRanges.find(opt => opt.value === value) || timeRanges[2]
105 | const { date_from, date_to } = currentOption.getRange()
106 |
107 | return {
108 | value,
109 | setValue,
110 | options: timeRanges,
111 | date_from,
112 | date_to,
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Select.module.css:
--------------------------------------------------------------------------------
1 | .trigger {
2 | display: flex;
3 | height: 32px;
4 | width: 100%;
5 | align-items: center;
6 | justify-content: space-between;
7 | border-radius: 8px;
8 | border: none;
9 | background-color: var(--background-color);
10 | box-shadow: inset 0 0 0 1px var(--border-02-color);
11 | color: var(--text-color);
12 | padding: 10px 12px;
13 | font-size: 14px;
14 | line-height: 20px;
15 | font-weight: 600;
16 | height: 40px;
17 | gap: 8px;
18 | outline: none;
19 | cursor: pointer;
20 | }
21 |
22 | .trigger[data-disabled] {
23 | cursor: not-allowed;
24 | opacity: 0.5;
25 | }
26 |
27 | .trigger:focus:not([data-disabled]) {
28 | box-shadow: inset 0 0 0 2px var(--border-03-color);
29 | }
30 |
31 | .trigger:hover:not([data-disabled]) {
32 | box-shadow: inset 0 0 0 2px var(--border-03-color);
33 | }
34 |
35 | .trigger[data-state='open'] {
36 | box-shadow: inset 0 0 0 2px var(--border-03-color);
37 | }
38 |
39 | .trigger[data-placeholder] {
40 | color: var(--text-01-color);
41 | }
42 |
43 | .trigger.dark {
44 | background-color: var(--background-dark-01-color);
45 | box-shadow: none;
46 | color: var(--text-inverse-color);
47 | }
48 |
49 | .icon {
50 | height: 16px;
51 | width: 16px;
52 | }
53 |
54 | .scrollButton {
55 | display: flex;
56 | cursor: default;
57 | align-items: center;
58 | justify-content: center;
59 | padding: 4px 0;
60 | color: var(--text-03-color);
61 | }
62 |
63 | .scrollIcon {
64 | height: 16px;
65 | width: 16px;
66 | }
67 |
68 | .content {
69 | position: relative;
70 | z-index: 50;
71 | min-width: 8rem;
72 | overflow: hidden;
73 | border-radius: 4px;
74 | border: 1px solid var(--border-01-color);
75 | background-color: var(--neutral-00-color);
76 | color: var(--text-color);
77 | box-shadow: var(--shadow-01);
78 | cursor: pointer;
79 | }
80 |
81 | .content.dark {
82 | --icon-color: var(--text-inverse-color);
83 | background-color: var(--background-dark-color);
84 | color: var(--text-inverse-color);
85 | border-color: var(--border-03-color);
86 | }
87 |
88 | .viewport {
89 | padding: 4px;
90 | }
91 |
92 | .viewportPopper {
93 | width: var(--radix-select-trigger-width);
94 | min-width: var(--radix-select-trigger-width);
95 | }
96 |
97 | .label {
98 | padding: 6px 8px;
99 | font-size: 14px;
100 | color: var(--text-03-color);
101 | text-transform: uppercase;
102 | letter-spacing: 0.05em;
103 | font-family: var(--font-mono);
104 | user-select: none;
105 | cursor: pointer;
106 | }
107 |
108 | .item {
109 | position: relative;
110 | display: flex;
111 | width: 100%;
112 | cursor: pointer;
113 | user-select: none;
114 | align-items: center;
115 | border-radius: 2px;
116 | padding: 6px 8px;
117 | font-size: 14px;
118 | outline: none;
119 | border-radius: 4px;
120 | width: 100%;
121 | }
122 |
123 | .item:hover,
124 | .item:focus {
125 | background-color: var(--background-02-color);
126 | }
127 |
128 | .item[data-state='checked'] {
129 | color: var(--text-color);
130 | }
131 |
132 | .item.dark:hover,
133 | .item.dark:focus {
134 | background-color: var(--background-dark-01-color);
135 | }
136 |
137 | .item.dark[data-state='checked'] {
138 | color: var(--text-inverse-color);
139 | }
140 |
141 | .item[data-disabled] {
142 | pointer-events: none;
143 | opacity: 0.5;
144 | }
145 |
146 | .itemIndicator {
147 | display: flex;
148 | align-items: center;
149 | justify-content: center;
150 | color: var(--icon-color);
151 | margin-left: auto;
152 | }
153 |
154 | .separator {
155 | margin: 4px 8px;
156 | height: 1px;
157 | background-color: var(--border-01-color);
158 | }
159 |
--------------------------------------------------------------------------------
/tinybird/endpoints/analytics_hits.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Parsed `page_hit` events, implementing `browser` and `device` detection logic.
3 | Use always with a limit and date_from, date_to filters
4 | Use like_filter for arbitrary text filter over the payload column which contains information about each page_hit
5 | Use the SQL of this endpoint to extract information from analytics_events for other actions
6 | Use the actions tool for a list of available action types, filter by 'action_filter' for arbitrary actions
7 |
8 | TOKEN "dashboard" READ
9 |
10 | NODE parsed_hits
11 | DESCRIPTION >
12 | Parse raw page_hit events
13 |
14 | SQL >
15 | %
16 | SELECT
17 | timestamp,
18 | action,
19 | version,
20 | coalesce(session_id, '0') as session_id,
21 | tenant_id,
22 | multiIf(domain != '', domain, current_domain != '', current_domain, domain_from_payload) as domain,
23 | JSONExtractString(payload, 'domain') as domain_from_payload,
24 | JSONExtractString(payload, 'locale') as locale,
25 | JSONExtractString(payload, 'location') as location,
26 | JSONExtractString(payload, 'referrer') as referrer,
27 | JSONExtractString(payload, 'pathname') as pathname,
28 | JSONExtractString(payload, 'href') as href,
29 | if(domainWithoutWWW(href) = '' and href is not null and href != '', URLHierarchy(href)[1], domainWithoutWWW(href)) as current_domain,
30 | lower(JSONExtractString(payload, 'user-agent')) as user_agent
31 | FROM analytics_events
32 | WHERE action = 'page_hit'
33 | {% if defined(tenant_id) %}
34 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
35 | {% end %}
36 | {% if defined(domain) %}
37 | AND domain = {{ String(domain, description="Filter by domain") }}
38 | {% end %}
39 | {% if defined(from_date) %}
40 | AND timestamp
41 | >=
42 | {{ Date(from_date, description="Starting date for filtering a date range", required=False) }}
43 | {% end %}
44 | {% if defined(to_date) %}
45 | AND timestamp
46 | <=
47 | {{ Date(to_date, description="Finishing date for filtering a date range", required=False) }}
48 | {% end %}
49 | {% if defined(like_filter) %}
50 | AND payload like {{String(filter, description="Filter to apply to the payload JSON string", example="%utm_%")}}
51 | {% end %}
52 | {% if defined(limit) %}
53 | LIMIT {{Int32(limit, 20)}}
54 | OFFSET {{Int32(page, 0) * Int32(limit, 20)}}
55 | {% end %}
56 |
57 | NODE endpoint
58 | SQL >
59 | SELECT
60 | timestamp,
61 | action,
62 | version,
63 | session_id,
64 | tenant_id,
65 | domain,
66 | location,
67 | referrer,
68 | pathname,
69 | href,
70 | current_domain,
71 | case
72 | when match(user_agent, 'wget|ahrefsbot|curl|urllib|bitdiscovery|\+https://|googlebot')
73 | then 'bot'
74 | when match(user_agent, 'android')
75 | then 'mobile-android'
76 | when match(user_agent, 'ipad|iphone|ipod')
77 | then 'mobile-ios'
78 | else 'desktop'
79 | END as device,
80 | case
81 | when match(user_agent, 'firefox')
82 | then 'firefox'
83 | when match(user_agent, 'chrome|crios')
84 | then 'chrome'
85 | when match(user_agent, 'opera')
86 | then 'opera'
87 | when match(user_agent, 'msie|trident')
88 | then 'ie'
89 | when match(user_agent, 'iphone|ipad|safari')
90 | then 'safari'
91 | else 'Unknown'
92 | END as browser
93 | FROM parsed_hits
94 |
95 | TYPE endpoint
--------------------------------------------------------------------------------
/dashboard/components/ui/Text.module.css:
--------------------------------------------------------------------------------
1 | .text {
2 | margin: 0;
3 | padding: 0;
4 | font-family: var(--font-family-sans);
5 | }
6 |
7 | /* Variants */
8 | .displaysmall {
9 | font-size: 24px;
10 | line-height: 32px;
11 | font-weight: 600;
12 | }
13 |
14 | .displayxsmall {
15 | font-size: 18px;
16 | line-height: 24px;
17 | font-weight: 600;
18 | }
19 |
20 | .displaymedium {
21 | font-size: 32px;
22 | line-height: 44px;
23 | font-weight: 600;
24 | }
25 |
26 | .heading {
27 | font-size: 20px;
28 | line-height: 28px;
29 | font-weight: 600;
30 | }
31 |
32 | .displaysmall {
33 | font-size: 24px;
34 | line-height: 32px;
35 | font-weight: 600;
36 | }
37 |
38 | .body {
39 | font-size: 14px;
40 | line-height: 20px;
41 | font-weight: 400;
42 | }
43 |
44 | .link {
45 | font-size: 14px;
46 | line-height: 20px;
47 | font-weight: 400;
48 | text-decoration: none;
49 |
50 | &:hover {
51 | text-decoration: underline;
52 | text-underline-offset: 3px;
53 | text-decoration-color: currentColor;
54 | }
55 | }
56 |
57 | .linksemibold {
58 | font-size: 14px;
59 | line-height: 20px;
60 | font-weight: 600;
61 | text-decoration: none;
62 |
63 | &:hover {
64 | text-decoration: underline;
65 | text-underline-offset: 3px;
66 | text-decoration-color: currentColor;
67 | }
68 | }
69 |
70 | .code {
71 | font-size: 14px;
72 | line-height: 24px;
73 | font-weight: 400;
74 | }
75 |
76 | .bodysemibold {
77 | font-size: 14px;
78 | line-height: 20px;
79 | font-weight: 600;
80 | }
81 |
82 | .button {
83 | font-size: 14px;
84 | line-height: 20px;
85 | font-weight: 600;
86 | }
87 |
88 | .caption {
89 | font-size: 12px;
90 | line-height: 16px;
91 | font-weight: 400;
92 | }
93 |
94 | .captionsemibold {
95 | font-size: 12px;
96 | line-height: 16px;
97 | font-weight: 600;
98 | }
99 |
100 | .captionbold {
101 | font-size: 12px;
102 | line-height: 16px;
103 | font-weight: 700;
104 | }
105 |
106 | .captioncode {
107 | font-size: 12px;
108 | line-height: 16px;
109 | font-weight: 400;
110 | }
111 |
112 | .captionlink {
113 | font-size: 12px;
114 | line-height: 16px;
115 | font-weight: 400;
116 | text-decoration: underline;
117 | text-underline-offset: 2px;
118 | }
119 |
120 | .smallcaptionbold {
121 | font-size: 8px;
122 | line-height: 16px;
123 | font-weight: 700;
124 | }
125 |
126 | /* Colors */
127 | .color-default {
128 | color: #25283d;
129 | }
130 |
131 | .color-01 {
132 | color: #636679;
133 | }
134 |
135 | .color-02 {
136 | color: var(--text-02-color);
137 | }
138 |
139 | .color-inverse {
140 | color: #ffffff;
141 | }
142 |
143 | .color-brand {
144 | color: #27f795;
145 | }
146 |
147 | .color-error {
148 | color: var(--text-error-color);
149 | }
150 |
151 | .color-inherit {
152 | color: inherit;
153 | }
154 |
155 | .color-alternative {
156 | color: var(--alternative-color);
157 | }
158 |
159 | .color-preview {
160 | color: #ff631a;
161 | }
162 |
163 | /* Alignment */
164 | .align-left {
165 | text-align: left;
166 | }
167 |
168 | .align-center {
169 | text-align: center;
170 | }
171 |
172 | .align-right {
173 | text-align: right;
174 | }
175 |
176 | /* Truncation */
177 | .truncate {
178 | white-space: nowrap;
179 | overflow: hidden;
180 | text-overflow: ellipsis;
181 | }
182 |
183 | .captioncode,
184 | .code {
185 | font-family: var(--font-family-mono);
186 | }
187 |
188 | .label {
189 | user-select: none;
190 | }
191 |
192 | .decoration-none {
193 | text-decoration: none;
194 | }
195 |
196 | .decoration-underline {
197 | text-decoration: underline;
198 | }
199 |
200 | .editable {
201 | cursor: text;
202 | border-bottom: 1px solid transparent;
203 | word-break: break-all;
204 | }
205 |
206 | .editable:hover,
207 | .editable:focus {
208 | border-bottom: 1px solid currentColor;
209 | }
210 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Chart.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | aspect-ratio: video;
4 | justify-content: center;
5 | text-align: center;
6 | font-size: 0.75rem;
7 | }
8 |
9 | /* Recharts specific styles */
10 | .container :global(.recharts-cartesian-axis-tick text) {
11 | fill: var(--text-01-color);
12 | }
13 |
14 | .container :global(.recharts-cartesian-grid line[stroke='#ccc']) {
15 | stroke: var(--border-01-color);
16 | opacity: 0.5;
17 | }
18 |
19 | .container :global(.recharts-curve.recharts-tooltip-cursor) {
20 | stroke: var(--border-01-color);
21 | }
22 |
23 | .container :global(.recharts-dot[stroke='#fff']) {
24 | stroke: transparent;
25 | }
26 |
27 | .container :global(.recharts-layer) {
28 | outline: none;
29 | }
30 |
31 | .container :global(.recharts-polar-grid [stroke='#ccc']) {
32 | stroke: var(--border-01-color);
33 | }
34 |
35 | .container :global(.recharts-radial-bar-background-sector) {
36 | fill: var(--background-01-color);
37 | }
38 |
39 | .container :global(.recharts-rectangle.recharts-tooltip-cursor) {
40 | fill: var(--background-01-color);
41 | }
42 |
43 | .container :global(.recharts-reference-line [stroke='#ccc']) {
44 | stroke: var(--border-01-color);
45 | }
46 |
47 | .container :global(.recharts-sector[stroke='#fff']) {
48 | stroke: transparent;
49 | }
50 |
51 | .container :global(.recharts-sector) {
52 | outline: none;
53 | }
54 |
55 | .container :global(.recharts-surface) {
56 | outline: none;
57 | }
58 |
59 | .tooltip {
60 | display: grid;
61 | min-width: 8rem;
62 | align-items: start;
63 | gap: 0.375rem;
64 | border-radius: 0.5rem;
65 | border: 1px solid var(--border-01-color);
66 | background-color: var(--background-color);
67 | padding: 0.625rem;
68 | font-size: 0.75rem;
69 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
70 | isolation: isolate;
71 | z-index: 1000;
72 | }
73 |
74 | .tooltipRow {
75 | display: flex;
76 | width: 100%;
77 | flex-wrap: wrap;
78 | align-items: stretch;
79 | gap: 0.5rem;
80 | }
81 |
82 | .tooltipRow svg {
83 | height: 0.625rem;
84 | width: 0.625rem;
85 | color: var(--background-01-color);
86 | }
87 |
88 | .tooltipRowDot {
89 | align-items: center;
90 | }
91 |
92 | .tooltipContent {
93 | display: flex;
94 | flex: 1;
95 | justify-content: space-between;
96 | line-height: 1;
97 | gap: 16px;
98 | }
99 |
100 | .tooltipContentGrid {
101 | display: grid;
102 | gap: 0.375rem;
103 | overflow-y: auto;
104 | max-height: 400px;
105 | }
106 |
107 | .tooltipContentNested {
108 | align-items: flex-end;
109 | }
110 |
111 | .tooltipContentDefault {
112 | align-items: center;
113 | }
114 |
115 | .tooltipValue {
116 | font-variant-numeric: tabular-nums;
117 | }
118 |
119 | .tooltipIndicator {
120 | flex-shrink: 0;
121 | border-radius: 2px;
122 | border-color: var(--color-border);
123 | background-color: var(--color-bg);
124 | }
125 |
126 | .tooltipIndicatorDot {
127 | height: 0.625rem;
128 | width: 0.625rem;
129 | }
130 |
131 | .tooltipIndicatorLine {
132 | width: 0.25rem;
133 | }
134 |
135 | .tooltipIndicatorDashed {
136 | width: 0;
137 | border: 1.5px dashed;
138 | background-color: transparent;
139 | }
140 |
141 | .tooltipIndicatorDashedNested {
142 | margin-block: 0.125rem;
143 | }
144 |
145 | .legend {
146 | display: flex;
147 | align-items: center;
148 | justify-content: center;
149 | gap: 1rem;
150 | }
151 |
152 | .legendTop {
153 | padding-bottom: 0.75rem;
154 | }
155 |
156 | .legendBottom {
157 | padding-top: 0.75rem;
158 | }
159 |
160 | .legendItem {
161 | display: flex;
162 | align-items: center;
163 | gap: 0.375rem;
164 | }
165 |
166 | .legendItem svg {
167 | height: 0.75rem;
168 | width: 0.75rem;
169 | color: var(--background-01-color);
170 | }
171 |
172 | .legendIndicator {
173 | height: 0.5rem;
174 | width: 0.5rem;
175 | flex-shrink: 0;
176 | border-radius: 2px;
177 | }
178 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/README.md:
--------------------------------------------------------------------------------
1 | # AI Chat Components
2 |
3 | This directory contains modular AI chat components that can be used independently or embedded in other applications.
4 |
5 | ## Components
6 |
7 | ### AIChatProvider
8 | Context provider that manages the chat state using `@ai-sdk/react`.
9 |
10 | ```tsx
11 | import { AIChatProvider } from '@/components/ai-chat'
12 |
13 |
14 | {/* Your chat components */}
15 |
16 | ```
17 |
18 | ### AIChatForm
19 | Input form component for user messages.
20 |
21 | ```tsx
22 | import { AIChatForm } from '@/components/ai-chat'
23 |
24 |
28 | ```
29 |
30 | ### AIChatMessage
31 | Displays individual chat messages with reasoning and results.
32 |
33 | ```tsx
34 | import { AIChatMessage } from '@/components/ai-chat'
35 |
36 |
40 | ```
41 |
42 | ### AIChatToolCall
43 | Handles visualization of tool calls (SQL charts, tables, etc.).
44 |
45 | ```tsx
46 | import { AIChatToolCall } from '@/components/ai-chat'
47 |
48 |
53 | ```
54 |
55 | ### AIChatContainer
56 | Main container that combines form and messages.
57 |
58 | ```tsx
59 | import { AIChatContainer } from '@/components/ai-chat'
60 |
61 |
66 | ```
67 |
68 | ### AIChatStandalone
69 | Complete standalone component that can be embedded anywhere.
70 |
71 | ```tsx
72 | import { AIChatStandalone } from '@/components/ai-chat'
73 |
74 |
79 | ```
80 |
81 | ## Usage Examples
82 |
83 | ### Basic Usage
84 | ```tsx
85 | import { AIChatStandalone } from '@/components/ai-chat'
86 |
87 | function MyPage() {
88 | return (
89 |
90 |
My Analytics Dashboard
91 |
92 |
93 | )
94 | }
95 | ```
96 |
97 | ### Custom Styling
98 | ```tsx
99 | import { AIChatStandalone } from '@/components/ai-chat'
100 |
101 | function MyPage() {
102 | return (
103 |
110 | )
111 | }
112 | ```
113 |
114 | ### Advanced Usage with Custom Components
115 | ```tsx
116 | import {
117 | AIChatProvider,
118 | AIChatForm,
119 | AIChatContainer
120 | } from '@/components/ai-chat'
121 |
122 | function MyCustomChat() {
123 | return (
124 |
125 |
129 |
130 | )
131 | }
132 | ```
133 |
134 | ## Embedding in Other Applications
135 |
136 | The components are designed to be easily embedded in other applications. Simply import the `AIChatStandalone` component and use it anywhere:
137 |
138 | ```tsx
139 | // In any React application
140 | import { AIChatStandalone } from '@your-org/ai-chat-components'
141 |
142 | function App() {
143 | return (
144 |
145 |
146 |
147 |
148 |
149 |
150 | )
151 | }
152 | ```
153 |
154 | ## Dependencies
155 |
156 | The components depend on:
157 | - `@ai-sdk/react` for chat functionality
158 | - Existing UI components from `@/components/ui/`
159 | - CSS variables for theming
160 |
161 | Make sure these dependencies are available in your target application.
--------------------------------------------------------------------------------
/tinybird/web_vitals/endpoints/web_vitals_current.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Returns current web vitals metrics (LCP, TTFB, FCP, INP, CLS) with average values over a configurable time period (defaults to 1 day), calculated scores, and detailed metric descriptions with units and scoring ranges.
3 |
4 | TOKEN "dashboard" READ
5 |
6 | NODE daily_vitals
7 | DESCRIPTION >
8 | Get web vital metrics aggregated over the specified time period with averages and metadata.
9 | Note: Unrealistic values (>60s for timing metrics, >1 for CLS) are filtered out as they indicate measurement errors.
10 |
11 | SQL >
12 |
13 | %
14 | SELECT
15 | metric_name,
16 | avg(value) as avg_value,
17 | quantile(0.75)(value) as p75,
18 | quantile(0.90)(value) as p90,
19 | quantile(0.95)(value) as p95,
20 | quantile(0.99)(value) as p99,
21 | avg(delta) as avg_delta,
22 | count() as measurements,
23 | any(units) as units,
24 | any(description) as description,
25 | any(thresholds_text) as thresholds_text,
26 | domain,
27 | tenant_id
28 | FROM web_vitals_events
29 | WHERE timestamp >= now() - interval {{Int32(days, 1, description="Number of days to analyze (defaults to 1 day)")}} day
30 | {% if defined(tenant_id) %}
31 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
32 | {% end %}
33 | {% if defined(domain) %}
34 | AND domain = {{ String(domain, description="Domain to filter web vitals for") }}
35 | {% end %}
36 | GROUP BY metric_name, domain, tenant_id
37 | HAVING measurements >= 1
38 |
39 | NODE endpoint
40 | DESCRIPTION >
41 | Calculate scores and status using built-in metadata from web_vitals_events
42 |
43 | SQL >
44 | SELECT
45 | metric_name,
46 | round(avg_value, 2) as avg_value,
47 | round(p75, 2) as p75,
48 | round(p90, 2) as p90,
49 | round(p90, 2) as p90,
50 | round(p95, 2) as p95,
51 | round(p99, 2) as p99,
52 | measurements,
53 | -- Calculate score based on thresholds (using same logic as web_vitals_events)
54 | CASE
55 | WHEN metric_name = 'LCP' AND avg_value <= 2500 THEN 100
56 | WHEN metric_name = 'LCP' AND avg_value <= 4000 THEN 75
57 | WHEN metric_name = 'TTFB' AND avg_value <= 500 THEN 100
58 | WHEN metric_name = 'TTFB' AND avg_value <= 1000 THEN 75
59 | WHEN metric_name = 'FCP' AND avg_value <= 1800 THEN 100
60 | WHEN metric_name = 'FCP' AND avg_value <= 3000 THEN 75
61 | WHEN metric_name = 'INP' AND avg_value <= 200 THEN 100
62 | WHEN metric_name = 'INP' AND avg_value <= 500 THEN 75
63 | WHEN metric_name = 'CLS' AND avg_value <= 0.1 THEN 100
64 | WHEN metric_name = 'CLS' AND avg_value <= 0.25 THEN 75
65 | ELSE 25
66 | END as score,
67 | -- Calculate status based on thresholds
68 | CASE
69 | WHEN metric_name = 'LCP' AND avg_value <= 2500 THEN 'Excellent'
70 | WHEN metric_name = 'LCP' AND avg_value <= 4000 THEN 'Good'
71 | WHEN metric_name = 'TTFB' AND avg_value <= 500 THEN 'Excellent'
72 | WHEN metric_name = 'TTFB' AND avg_value <= 1000 THEN 'Good'
73 | WHEN metric_name = 'FCP' AND avg_value <= 1800 THEN 'Excellent'
74 | WHEN metric_name = 'FCP' AND avg_value <= 3000 THEN 'Good'
75 | WHEN metric_name = 'INP' AND avg_value <= 200 THEN 'Excellent'
76 | WHEN metric_name = 'INP' AND avg_value <= 500 THEN 'Good'
77 | WHEN metric_name = 'CLS' AND avg_value <= 0.1 THEN 'Excellent'
78 | WHEN metric_name = 'CLS' AND avg_value <= 0.25 THEN 'Good'
79 | ELSE 'Poor'
80 | END as status,
81 | units,
82 | description,
83 | thresholds_text as thresholds,
84 | domain,
85 | tenant_id
86 | FROM daily_vitals
87 | ORDER BY metric_name
88 |
89 | TYPE endpoint
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/AIChatContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useAIChat } from './AIChatProvider'
5 | import { AIChatForm } from './AIChatForm'
6 | import { AIChatMessage } from './AIChatMessage'
7 | import { cn } from '@/lib/utils'
8 | import { Text } from '../ui/Text'
9 | import { motion } from 'motion/react'
10 | import { Card } from '../ui/Card'
11 | import { Loader } from '../ui/Loader'
12 |
13 | interface AIChatContainerProps {
14 | placeholder?: string
15 | className?: string
16 | showForm?: boolean
17 | }
18 |
19 | const AIChatCredits = () => {
20 | return (
21 |
26 |
27 |
28 |
29 |
30 | Powered by Tinybird
31 |
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | const AnalyzingCard = () => {
39 | const REASONING_HEIGHT = 220
40 | const ANIMATION_CONFIG = {
41 | duration: 0.1,
42 | delay: 0.1,
43 | scale: { type: 'inertia' as const, visualDuration: 0.2 },
44 | }
45 |
46 | return (
47 |
56 |
63 |
77 |
82 |
83 |
84 |
85 | Analyzing your question…
86 |
87 |
88 |
89 |
90 |
91 |
92 | )
93 | }
94 |
95 | export function AIChatContainer({
96 | placeholder,
97 | className = '',
98 | showForm = true,
99 | }: AIChatContainerProps) {
100 | const { messages, isLoading, error, status } = useAIChat()
101 |
102 | const cards = messages
103 | .filter(
104 | (message: any) =>
105 | message.role === 'system' || message.role === 'assistant'
106 | )
107 | .map((message: any, index: number) => ({
108 | message,
109 | index,
110 | }))
111 |
112 | return (
113 |
114 |
115 |
116 | {showForm &&
}
117 |
118 |
119 | {cards.map(({ message, index }) => {
120 | return (
121 |
122 | )
123 | })}
124 |
125 | {/* Show analyzing card only when loading and no messages yet */}
126 | {isLoading && cards.length === 0 &&
}
127 |
128 |
129 | )
130 | }
131 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Button.module.css:
--------------------------------------------------------------------------------
1 | .base {
2 | display: inline-flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | gap: 8px;
6 | white-space: nowrap;
7 | border-radius: 4px;
8 | border: 2px solid;
9 | border-color: transparent;
10 | font-size: 14px;
11 | line-height: 20px;
12 | font-weight: 600;
13 | transition: colors 0.2s;
14 | cursor: pointer;
15 | font: var(--font-button);
16 | }
17 |
18 | .content {
19 | display: inline-flex;
20 | align-items: center;
21 | justify-content: flex-start;
22 | gap: 8px;
23 | white-space: nowrap;
24 | position: relative;
25 | width: 100%;
26 | }
27 |
28 | .fullWidth {
29 | width: 100%;
30 | }
31 |
32 | .fullWidth .content {
33 | justify-content: center;
34 | }
35 |
36 | .base:focus-visible {
37 | outline: none;
38 | }
39 |
40 | .base:disabled {
41 | opacity: 0.5;
42 | cursor: not-allowed;
43 | }
44 |
45 | .base svg {
46 | pointer-events: none;
47 | width: 16px;
48 | height: 16px;
49 | flex-shrink: 0;
50 | }
51 |
52 | .primary.solid {
53 | background-color: var(--primary-color);
54 | color: var(--text-color);
55 | }
56 |
57 | .primary.solid:hover:not(:disabled),
58 | .primary.solid:focus-visible:not(:disabled) {
59 | border-color: var(--primary-dark-color);
60 | }
61 |
62 | .error.solid {
63 | background-color: var(--error-color);
64 | color: var(--text-inverse-color);
65 | }
66 |
67 | .error.solid:hover:not(:disabled),
68 | .error.solid:focus-visible:not(:disabled) {
69 | border-color: var(--error-dark-color);
70 | }
71 |
72 | .error.outline {
73 | border-width: 1px;
74 | border-color: var(--error-color);
75 | color: var(--error-color);
76 | }
77 |
78 | .error.outline:hover:not(:disabled),
79 | .error.outline:focus-visible:not(:disabled) {
80 | border-color: var(--error-dark-color);
81 | }
82 |
83 | .dark.solid {
84 | background-color: var(--background-dark-01-color);
85 | color: var(--text-inverse-color);
86 | }
87 |
88 | .dark.solid:hover:not(:disabled),
89 | .dark.solid:focus-visible:not(:disabled) {
90 | border-color: transparent;
91 | }
92 |
93 | .dark.outline {
94 | border-width: 1px;
95 | border-color: var(--border-03-color);
96 | color: var(--text-inverse-color);
97 | background-color: var(--background-dark-color);
98 | }
99 |
100 | .dark.outline:hover:not(:disabled),
101 | .dark.outline:focus-visible:not(:disabled) {
102 | border-color: var(--border-03-color);
103 | box-shadow: inset 0 0 0 1px var(--border-03-color);
104 | }
105 |
106 | .secondary.outline {
107 | border-width: 1px;
108 | border-color: var(--border-02-color);
109 | color: var(--secondary-color);
110 | background-color: var(--neutral-00-color);
111 | }
112 |
113 | .secondary.outline:hover:not(:disabled),
114 | .secondary.outline:focus-visible:not(:disabled) {
115 | border-color: var(--border-03-color);
116 | box-shadow: inset 0 0 0 1px var(--border-03-color);
117 | }
118 |
119 | .secondary.text {
120 | color: var(--secondary-color);
121 | padding: 0;
122 | }
123 |
124 | .secondary.text:hover:not(:disabled),
125 | .secondary.text:focus-visible:not(:disabled) {
126 | color: var(--secondary-dark-color);
127 | }
128 |
129 | .error.text {
130 | color: var(--error-color);
131 | padding: 0;
132 | }
133 |
134 | .sizeSmall {
135 | height: 24px;
136 | padding: 0 8px;
137 | }
138 |
139 | .sizeSmall.outline {
140 | font-weight: 400;
141 | }
142 |
143 | .sizeMedium {
144 | height: 32px;
145 | padding: 0 12px;
146 | }
147 |
148 | .sizeLarge {
149 | height: 40px;
150 | padding: 0 16px;
151 | }
152 |
153 | .loading > *:not(.loadingOverlay) {
154 | opacity: 0;
155 | }
156 |
157 | .loading {
158 | color: transparent;
159 | }
160 |
161 | .loadingOverlay {
162 | position: absolute;
163 | inset: 0;
164 | display: grid;
165 | place-content: center;
166 | color: var(--text-color);
167 | z-index: 1;
168 | & + *,
169 | & + * + *,
170 | & + * + * + * {
171 | opacity: 0;
172 | }
173 | }
174 |
175 | .sizeIcon {
176 | padding: 0;
177 | height: 24px;
178 | }
179 |
--------------------------------------------------------------------------------
/dashboard/components/ui/Text.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@/lib/utils'
4 | import { forwardRef, useRef } from 'react'
5 | import ContentEditable from 'react-contenteditable'
6 | import styles from './Text.module.css'
7 | import DOMPurify from 'dompurify'
8 |
9 | export type TextVariant =
10 | | 'displaysmall'
11 | | 'displayxsmall'
12 | | 'displaysmall'
13 | | 'displaymedium'
14 | | 'heading'
15 | | 'body'
16 | | 'bodysemibold'
17 | | 'link'
18 | | 'linksemibold'
19 | | 'code'
20 | | 'button'
21 | | 'caption'
22 | | 'captionbold'
23 | | 'captioncode'
24 | | 'captionlink'
25 | | 'captionsemibold'
26 | | 'smallcaptionbold'
27 |
28 | export type TextColor =
29 | | 'default'
30 | | 'alternative'
31 | | '01'
32 | | '02'
33 | | 'inverse'
34 | | 'brand'
35 | | 'error'
36 | | 'inherit'
37 | | 'preview'
38 |
39 | export type TextAlign = 'left' | 'center' | 'right'
40 |
41 | export type TextProps = React.ComponentProps<'span'> & {
42 | as?: React.ElementType
43 | variant?: TextVariant
44 | color?: TextColor
45 | style?: React.CSSProperties
46 | align?: TextAlign
47 | decoration?: React.CSSProperties['textDecoration']
48 | truncate?: boolean
49 | role?: React.AriaRole
50 | htmlFor?: string
51 | }
52 |
53 | export const Text = forwardRef(
54 | (
55 | {
56 | children,
57 | className,
58 | as: Component = 'span',
59 | variant = 'body',
60 | color = 'inherit',
61 | align = 'left',
62 | truncate = false,
63 | decoration,
64 | ...props
65 | },
66 | ref
67 | ) => {
68 | return (
69 |
83 | {children}
84 |
85 | )
86 | }
87 | )
88 |
89 | Text.displayName = 'Text'
90 |
91 | export function TextInput({
92 | value: defaultValue,
93 | as = 'span',
94 | variant = 'displayxsmall',
95 | color = 'inherit',
96 | align = 'left',
97 | decoration,
98 | truncate = false,
99 | className,
100 | onChange,
101 | disabled,
102 | ...props
103 | }: {
104 | onChange: (value: string) => void
105 | value: string
106 | as?: string
107 | variant?: TextVariant
108 | color?: TextColor
109 | align?: TextAlign
110 | decoration?: React.CSSProperties['textDecoration']
111 | truncate?: boolean
112 | className?: string
113 | disabled?: boolean
114 | }) {
115 | const currentValue = useRef(defaultValue)
116 | return (
117 | ) => {
135 | if (e.key === 'Enter' || e.key === 'Escape') {
136 | e.currentTarget.blur()
137 | e.preventDefault()
138 | }
139 | }}
140 | onBlur={() => {
141 | onChange?.(currentValue.current)
142 | }}
143 | onChange={e => {
144 | currentValue.current = sanitizeValue(e.target.value)
145 | }}
146 | html={sanitizeValue(currentValue.current)}
147 | />
148 | )
149 | }
150 |
151 | function sanitizeValue(value = ''): string {
152 | return DOMPurify.sanitize(value, { ALLOWED_TAGS: [] })
153 | }
154 |
--------------------------------------------------------------------------------
/tinybird/fixtures/analytics_events.sql.backup:
--------------------------------------------------------------------------------
1 | SELECT
2 | now() - INTERVAL rand() % (18 * 30) DAY - INTERVAL rand() % 24 HOUR - INTERVAL rand() % 60 MINUTE - INTERVAL rand() % 60 SECOND AS timestamp,
3 | if(rand() % 10 > 0, concat(
4 | lower(hex(rand())), lower(hex(rand())), '-',
5 | lower(hex(rand())), '-',
6 | lower(hex(rand())), '-',
7 | lower(hex(rand())), '-',
8 | lower(hex(rand())), lower(hex(rand()))
9 | ), NULL) AS session_id,
10 | ['page_hit', 'web_vital'][1 + (rand() % 2)] AS action,
11 | concat('v', toString(1 + rand() % 3), '.', toString(rand() % 3), '.', toString(rand() % 9)) AS version,
12 | if(
13 | ['page_hit', 'web_vital'][1 + (rand() % 2)] = 'page_hit',
14 | /* PAGE_HIT payload */
15 | CAST(concat('{
16 | "pathname": "', ['/', '/home', '/about', '/pricing', '/contact', '/blog', '/login', '/product'][1 + rand() % 8], '",
17 | "href": "https://', domain, ['/', '/home', '/about', '/pricing', '/contact', '/blog', '/login', '/product'][1 + rand() % 8], '",
18 | "referrer": "', ['https://google.com', 'https://facebook.com', 'https://twitter.com', ''][1 + rand() % 4], '",
19 | "userAgent": "Mozilla/5.0 (', ['Windows NT 10.0', 'Macintosh', 'Linux', 'iPhone', 'Android'][1 + rand() % 5], ')",
20 | "locale": "', ['en-US', 'fr-FR', 'de-DE', 'es-ES', 'it-IT', 'zh-CN'][1 + rand() % 6], '",
21 | "location": {
22 | "country": "', ['United States', 'Canada', 'United Kingdom', 'Germany', 'France', 'Spain', 'Italy', 'Japan', 'China'][1 + rand() % 9], '",
23 | "city": "', ['New York', 'London', 'Paris', 'Berlin', 'Madrid', 'Tokyo', 'Beijing', 'Los Angeles', 'Boston'][1 + rand() % 9], '"
24 | }
25 | }'), 'String'),
26 | /* WEB_VITAL payload */
27 | CAST(concat('{
28 | "name": "', ['LCP', 'TTFB', 'FCP', 'INP', 'CLS'][1 + rand() % 5], '",
29 | "value": ',
30 | case
31 | when ['LCP', 'TTFB', 'FCP', 'INP', 'CLS'][1 + rand() % 5] = 'LCP' then toString(1500 + rand() % 3000)
32 | when ['LCP', 'TTFB', 'FCP', 'INP', 'CLS'][1 + rand() % 5] = 'TTFB' then toString(200 + rand() % 1000)
33 | when ['LCP', 'TTFB', 'FCP', 'INP', 'CLS'][1 + rand() % 5] = 'FCP' then toString(800 + rand() % 2400)
34 | when ['LCP', 'TTFB', 'FCP', 'INP', 'CLS'][1 + rand() % 5] = 'INP' then toString(100 + rand() % 500)
35 | when ['LCP', 'TTFB', 'FCP', 'INP', 'CLS'][1 + rand() % 5] = 'CLS' then toString(round(0.05 + rand() % 30 / 100, 2))
36 | else toString(1000 + rand() % 2000)
37 | end, ',
38 | "delta": ', toString(50 + rand() % 150), ',
39 | "pathname": "', ['/', '/home', '/about', '/pricing', '/contact', '/blog', '/login', '/product'][1 + rand() % 8], '",
40 | "domain": "', domain, '"
41 | }'), 'String')
42 | ) AS payload,
43 | tenant_id,
44 | domain
45 | FROM (
46 | SELECT
47 | multiIf(
48 | rand() % 100 < 5, '', /* 5% events with empty tenant_id */
49 | rand() % 100 < 14, 'tenant_1',
50 | rand() % 100 < 28, 'tenant_2',
51 | rand() % 100 < 42, 'tenant_3',
52 | rand() % 100 < 56, 'tenant_8',
53 | rand() % 100 < 70, 'tenant_14',
54 | rand() % 100 < 84, 'tenant_18',
55 | 'tenant_' || toString(rand() % 5 + 4) /* other random tenants */
56 | ) AS tenant_id,
57 | multiIf(
58 | tenant_id = '', '', /* empty domain for empty tenant_id */
59 | tenant_id = 'tenant_1', ['example.com', 'demo.site'][1 + rand() % 2],
60 | tenant_id = 'tenant_2', ['myapp.com', 'test.org'][1 + rand() % 2],
61 | tenant_id = 'tenant_3', ['app.demo.io', 'business.net'][1 + rand() % 2],
62 | tenant_id = 'tenant_8', 'test.org',
63 | tenant_id = 'tenant_14', 'app.demo.io',
64 | tenant_id = 'tenant_18', 'test.org',
65 | ['other.com', 'example.org', 'test.com'][1 + rand() % 3] /* random domains for other tenants */
66 | ) AS domain
67 | FROM numbers(10000)
68 | )
69 |
--------------------------------------------------------------------------------
/tinybird/mock/schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "timestamp": {
3 | "type": "mockingbird.timestampNow"
4 | },
5 | "session_id": {
6 | "type": "string.uuid"
7 | },
8 | "action": {
9 | "type": "mockingbird.pick",
10 | "params": [
11 | {
12 | "values": [
13 | "page_hit"
14 | ]
15 | }
16 | ]
17 | },
18 | "version": {
19 | "type": "mockingbird.pick",
20 | "params": [
21 | {
22 | "values": [
23 | "1"
24 | ]
25 | }
26 | ]
27 | },
28 | "payload": {
29 | "type": "mockingbird.pick",
30 | "params": [
31 | {
32 | "values": [
33 | "{ \"user-agent\":\"Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.79 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\", \"locale\":\"en-US\", \"referrer\":\"https://www.kike.io\", \"pathname\":\"/blog-posts/data-market-whitebox-replaces-4-data-stack-tools-with-tinybird\", \"href\":\"https://www.tinybird.co/blog-posts/data-market-whitebox-replaces-4-data-stack-tools-with-tinybird\"}",
34 | "{ \"user-agent\":\"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/104.0.5112.79 Safari/537.36\", \"locale\":\"en-US\", \"location\":\"IT\", \"referrer\":\"https://www.hn.com\", \"pathname\":\"/guide/fine-tuning-csvs-for-fast-ingestion\", \"href\":\"https://www.tinybird.co/guide/fine-tuning-csvs-for-fast-ingestion\"}",
35 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:103.0) Gecko/20100101 Firefox/103.0\", \"locale\":\"en-GB\", \"location\":\"ES\", \"referrer\":\"\", \"pathname\":\"/\", \"href\":\"https://www.tinybird.co\"}",
36 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:103.0) Gecko/20100101 Firefox/103.0\", \"locale\":\"en-US\", \"location\":\"US\", \"referrer\":\"https://www.google.com\", \"pathname\":\"/\", \"href\":\"https://www.tinybird.co\"}",
37 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36\", \"locale\":\"en-US\", \"location\":\"US\", \"referrer\":\"https://www.tinybird.co/why-tinybird\", \"pathname\":\"/pricing\", \"href\":\"https://www.tinybird.co/pricing\"}",
38 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36\", \"locale\":\"en-US\", \"location\":\"US\", \"referrer\":\"https://www.google.com\", \"pathname\":\"/product\", \"href\":\"https://www.tinybird.co/product\"}",
39 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36\", \"locale\":\"en-US\", \"location\":\"IL\", \"referrer\":\"https://www.google.com\", \"pathname\":\"/blog-posts/tips-5-adding-and-subtracting-intervals\", \"href\":\"https://www.tinybird.co/blog-posts/tips-5-adding-and-subtracting-intervals\"}",
40 | "{ \"user-agent\":\"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1\", \"locale\":\"es-ES\", \"location\":\"ES\", \"referrer\":\"https://www.twitter.com\", \"pathname\":\"/\", \"href\":\"https://www.tinybird.co\"}",
41 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36\", \"locale\":\"en-US\", \"location\":\"GB\", \"referrer\":\"https://www.facebook.com\", \"pathname\":\"/\", \"href\":\"https://www.tinybird.co\"}",
42 | "{ \"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36\", \"locale\":\"en-US\", \"location\":\"CH\", \"referrer\":\"https://www.qq.ch\", \"pathname\":\"guides\", \"href\":\"https://www.tinybird.co/guides\"}",
43 | "{ \"user-agent\":\"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.118 Mobile Safari/537.36\", \"locale\":\"en-US\", \"location\":\"US\", \"referrer\":\"https://www.yandex.com\", \"pathname\":\"/product\", \"href\":\"https://www.tinybird.co/product\"}",
44 | "{ \"user-agent\":\"Mozilla/5.0 (Linux; Android 13; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.118 Mobile Safari/537.36\", \"locale\":\"en-US\", \"location\":\"FR\", \"referrer\":\"https://www.github.com\", \"pathname\":\"/pricing\", \"href\":\"https://www.tinybird.co/pricing\"}"
45 | ]
46 | }
47 | ]
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tinybird/endpoints/top_devices.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Top Device Types ordered by most visits.
3 | Accepts `date_from` and `date_to` date filter. Defaults to last 7 days.
4 | Set `include_previous_period=true` to get previous period metrics and growth percentage.
5 | Also `skip` and `limit` parameters for pagination.
6 |
7 | TOKEN "dashboard" READ
8 |
9 | NODE date_calculations
10 | DESCRIPTION >
11 | Calculate current and previous period date ranges
12 |
13 | SQL >
14 | %
15 | WITH
16 | {% if defined(date_from) and defined(date_to) %}
17 | toDate({{ String(date_from) }}) as current_start,
18 | toDate({{ String(date_to) }}) as current_end,
19 | {% else %}
20 | toDate(timestampAdd(today(), interval -7 day)) as current_start,
21 | toDate(today()) as current_end,
22 | {% end %}
23 | dateDiff('day', current_start, current_end) + 1 as period_days,
24 | timestampAdd(current_start, interval -period_days day) as previous_start,
25 | timestampAdd(current_end, interval -period_days day) as previous_end
26 | SELECT
27 | current_start,
28 | current_end,
29 | previous_start,
30 | previous_end,
31 | period_days
32 |
33 | NODE current_period_data
34 | DESCRIPTION >
35 | Get device metrics for the current period
36 |
37 | SQL >
38 | %
39 | SELECT
40 | device,
41 | uniq(session_id) as current_visits,
42 | countMerge(hits) as current_hits
43 | FROM analytics_sessions_mv
44 | WHERE date >= (SELECT current_start FROM date_calculations)
45 | AND date <= (SELECT current_end FROM date_calculations)
46 | {% if defined(tenant_id) %}
47 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
48 | {% end %}
49 | {% if defined(domain) %}
50 | AND domain = {{ String(domain, description="Filter by domain") }}
51 | {% end %}
52 | GROUP BY device
53 |
54 | NODE previous_period_data
55 | DESCRIPTION >
56 | Get device metrics for the previous period (only when include_previous_period is true)
57 |
58 | SQL >
59 | %
60 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
61 | SELECT
62 | device,
63 | uniq(session_id) as previous_visits,
64 | countMerge(hits) as previous_hits
65 | FROM analytics_sessions_mv
66 | WHERE date >= (SELECT previous_start FROM date_calculations)
67 | AND date <= (SELECT previous_end FROM date_calculations)
68 | {% if defined(tenant_id) %}
69 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
70 | {% end %}
71 | {% if defined(domain) %}
72 | AND domain = {{ String(domain, description="Filter by domain") }}
73 | {% end %}
74 | GROUP BY device
75 | {% else %}
76 | SELECT '' as device, 0 as previous_visits, 0 as previous_hits WHERE 1=0
77 | {% end %}
78 |
79 | NODE endpoint
80 | DESCRIPTION >
81 | Combine current and previous period data with growth calculations
82 |
83 | SQL >
84 | %
85 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
86 | SELECT
87 | c.device,
88 | c.current_visits as visits,
89 | c.current_hits as hits,
90 | coalesce(p.previous_visits, 0) as previous_visits,
91 | coalesce(p.previous_hits, 0) as previous_hits,
92 | CASE
93 | WHEN coalesce(p.previous_visits, 0) = 0 AND c.current_visits > 0 THEN 100.0
94 | WHEN coalesce(p.previous_visits, 0) = 0 THEN 0.0
95 | ELSE round(((c.current_visits - coalesce(p.previous_visits, 0)) * 100.0) / p.previous_visits, 2)
96 | END as visits_growth_percentage,
97 | CASE
98 | WHEN coalesce(p.previous_hits, 0) = 0 AND c.current_hits > 0 THEN 100.0
99 | WHEN coalesce(p.previous_hits, 0) = 0 THEN 0.0
100 | ELSE round(((c.current_hits - coalesce(p.previous_hits, 0)) * 100.0) / p.previous_hits, 2)
101 | END as hits_growth_percentage
102 | FROM current_period_data c
103 | LEFT JOIN previous_period_data p ON c.device = p.device
104 | ORDER BY c.current_visits DESC
105 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
106 | {% else %}
107 | SELECT
108 | device,
109 | current_visits as visits,
110 | current_hits as hits
111 | FROM current_period_data
112 | ORDER BY current_visits DESC
113 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
114 | {% end %}
115 |
116 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/endpoints/top_browsers.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Top Browsers ordered by most visits.
3 | Accepts `date_from` and `date_to` date filter. Defaults to last 7 days.
4 | Set `include_previous_period=true` to get previous period metrics and growth percentage.
5 | Also `skip` and `limit` parameters for pagination.
6 |
7 | TOKEN "dashboard" READ
8 |
9 | NODE date_calculations
10 | DESCRIPTION >
11 | Calculate current and previous period date ranges
12 |
13 | SQL >
14 | %
15 | WITH
16 | {% if defined(date_from) and defined(date_to) %}
17 | toDate({{ String(date_from) }}) as current_start,
18 | toDate({{ String(date_to) }}) as current_end,
19 | {% else %}
20 | toDate(timestampAdd(today(), interval -7 day)) as current_start,
21 | toDate(today()) as current_end,
22 | {% end %}
23 | dateDiff('day', current_start, current_end) + 1 as period_days,
24 | timestampAdd(current_start, interval -period_days day) as previous_start,
25 | timestampAdd(current_end, interval -period_days day) as previous_end
26 | SELECT
27 | current_start,
28 | current_end,
29 | previous_start,
30 | previous_end,
31 | period_days
32 |
33 | NODE current_period_data
34 | DESCRIPTION >
35 | Get browser metrics for the current period
36 |
37 | SQL >
38 | %
39 | SELECT
40 | browser,
41 | uniq(session_id) as current_visits,
42 | countMerge(hits) as current_hits
43 | FROM analytics_sessions_mv
44 | WHERE date >= (SELECT current_start FROM date_calculations)
45 | AND date <= (SELECT current_end FROM date_calculations)
46 | {% if defined(tenant_id) %}
47 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
48 | {% end %}
49 | {% if defined(domain) %}
50 | AND domain = {{ String(domain, description="Filter by domain") }}
51 | {% end %}
52 | GROUP BY browser
53 |
54 | NODE previous_period_data
55 | DESCRIPTION >
56 | Get browser metrics for the previous period (only when include_previous_period is true)
57 |
58 | SQL >
59 | %
60 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
61 | SELECT
62 | browser,
63 | uniq(session_id) as previous_visits,
64 | countMerge(hits) as previous_hits
65 | FROM analytics_sessions_mv
66 | WHERE date >= (SELECT previous_start FROM date_calculations)
67 | AND date <= (SELECT previous_end FROM date_calculations)
68 | {% if defined(tenant_id) %}
69 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
70 | {% end %}
71 | {% if defined(domain) %}
72 | AND domain = {{ String(domain, description="Filter by domain") }}
73 | {% end %}
74 | GROUP BY browser
75 | {% else %}
76 | SELECT '' as browser, 0 as previous_visits, 0 as previous_hits WHERE 1=0
77 | {% end %}
78 |
79 | NODE endpoint
80 | DESCRIPTION >
81 | Combine current and previous period data with growth calculations
82 |
83 | SQL >
84 | %
85 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
86 | SELECT
87 | c.browser,
88 | c.current_visits as visits,
89 | c.current_hits as hits,
90 | coalesce(p.previous_visits, 0) as previous_visits,
91 | coalesce(p.previous_hits, 0) as previous_hits,
92 | CASE
93 | WHEN coalesce(p.previous_visits, 0) = 0 AND c.current_visits > 0 THEN 100.0
94 | WHEN coalesce(p.previous_visits, 0) = 0 THEN 0.0
95 | ELSE round(((c.current_visits - coalesce(p.previous_visits, 0)) * 100.0) / p.previous_visits, 2)
96 | END as visits_growth_percentage,
97 | CASE
98 | WHEN coalesce(p.previous_hits, 0) = 0 AND c.current_hits > 0 THEN 100.0
99 | WHEN coalesce(p.previous_hits, 0) = 0 THEN 0.0
100 | ELSE round(((c.current_hits - coalesce(p.previous_hits, 0)) * 100.0) / p.previous_hits, 2)
101 | END as hits_growth_percentage
102 | FROM current_period_data c
103 | LEFT JOIN previous_period_data p ON c.browser = p.browser
104 | ORDER BY c.current_visits DESC
105 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
106 | {% else %}
107 | SELECT
108 | browser,
109 | current_visits as visits,
110 | current_hits as hits
111 | FROM current_period_data
112 | ORDER BY current_visits DESC
113 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
114 | {% end %}
115 |
116 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/endpoints/top_locations.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Top visting Countries ordered by most visits.
3 | Accepts `date_from` and `date_to` date filter. Defaults to last 7 days.
4 | Set `include_previous_period=true` to get previous period metrics and growth percentage.
5 | Also `skip` and `limit` parameters for pagination.
6 |
7 | TOKEN "dashboard" READ
8 |
9 | NODE date_calculations
10 | DESCRIPTION >
11 | Calculate current and previous period date ranges
12 |
13 | SQL >
14 | %
15 | WITH
16 | {% if defined(date_from) and defined(date_to) %}
17 | toDate({{ String(date_from) }}) as current_start,
18 | toDate({{ String(date_to) }}) as current_end,
19 | {% else %}
20 | toDate(timestampAdd(today(), interval -7 day)) as current_start,
21 | toDate(today()) as current_end,
22 | {% end %}
23 | dateDiff('day', current_start, current_end) + 1 as period_days,
24 | timestampAdd(current_start, interval -period_days day) as previous_start,
25 | timestampAdd(current_end, interval -period_days day) as previous_end
26 | SELECT
27 | current_start,
28 | current_end,
29 | previous_start,
30 | previous_end,
31 | period_days
32 |
33 | NODE current_period_data
34 | DESCRIPTION >
35 | Get location metrics for the current period
36 |
37 | SQL >
38 | %
39 | SELECT
40 | location,
41 | uniqMerge(visits) as current_visits,
42 | countMerge(hits) as current_hits
43 | FROM analytics_pages_mv
44 | WHERE date >= (SELECT current_start FROM date_calculations)
45 | AND date <= (SELECT current_end FROM date_calculations)
46 | {% if defined(tenant_id) %}
47 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
48 | {% end %}
49 | {% if defined(domain) %}
50 | AND domain = {{ String(domain, description="Filter by domain") }}
51 | {% end %}
52 | GROUP BY location
53 |
54 | NODE previous_period_data
55 | DESCRIPTION >
56 | Get location metrics for the previous period (only when include_previous_period is true)
57 |
58 | SQL >
59 | %
60 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
61 | SELECT
62 | location,
63 | uniqMerge(visits) as previous_visits,
64 | countMerge(hits) as previous_hits
65 | FROM analytics_pages_mv
66 | WHERE date >= (SELECT previous_start FROM date_calculations)
67 | AND date <= (SELECT previous_end FROM date_calculations)
68 | {% if defined(tenant_id) %}
69 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
70 | {% end %}
71 | {% if defined(domain) %}
72 | AND domain = {{ String(domain, description="Filter by domain") }}
73 | {% end %}
74 | GROUP BY location
75 | {% else %}
76 | SELECT '' as location, 0 as previous_visits, 0 as previous_hits WHERE 1=0
77 | {% end %}
78 |
79 | NODE endpoint
80 | DESCRIPTION >
81 | Combine current and previous period data with growth calculations
82 |
83 | SQL >
84 | %
85 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
86 | SELECT
87 | c.location,
88 | c.current_visits as visits,
89 | c.current_hits as hits,
90 | coalesce(p.previous_visits, 0) as previous_visits,
91 | coalesce(p.previous_hits, 0) as previous_hits,
92 | CASE
93 | WHEN coalesce(p.previous_visits, 0) = 0 AND c.current_visits > 0 THEN 100.0
94 | WHEN coalesce(p.previous_visits, 0) = 0 THEN 0.0
95 | ELSE round(((c.current_visits - coalesce(p.previous_visits, 0)) * 100.0) / p.previous_visits, 2)
96 | END as visits_growth_percentage,
97 | CASE
98 | WHEN coalesce(p.previous_hits, 0) = 0 AND c.current_hits > 0 THEN 100.0
99 | WHEN coalesce(p.previous_hits, 0) = 0 THEN 0.0
100 | ELSE round(((c.current_hits - coalesce(p.previous_hits, 0)) * 100.0) / p.previous_hits, 2)
101 | END as hits_growth_percentage
102 | FROM current_period_data c
103 | LEFT JOIN previous_period_data p ON c.location = p.location
104 | ORDER BY c.current_visits DESC
105 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
106 | {% else %}
107 | SELECT
108 | location,
109 | current_visits as visits,
110 | current_hits as hits
111 | FROM current_period_data
112 | ORDER BY current_visits DESC
113 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
114 | {% end %}
115 |
116 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/endpoints/top_pages.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Most visited pages for a given period with optional previous period comparison and growth percentage.
3 | Accepts `date_from` and `date_to` date filter. Defaults to last 7 days.
4 | Set `include_previous_period=true` to get previous period metrics and growth percentage.
5 | Also `skip` and `limit` parameters for pagination.
6 |
7 | TOKEN "dashboard" READ
8 |
9 | NODE date_calculations
10 | DESCRIPTION >
11 | Calculate current and previous period date ranges
12 |
13 | SQL >
14 | %
15 | WITH
16 | {% if defined(date_from) and defined(date_to) %}
17 | toDate({{ String(date_from) }}) as current_start,
18 | toDate({{ String(date_to) }}) as current_end,
19 | {% else %}
20 | toDate(timestampAdd(today(), interval -7 day)) as current_start,
21 | toDate(today()) as current_end,
22 | {% end %}
23 | dateDiff('day', current_start, current_end) + 1 as period_days,
24 | timestampAdd(current_start, interval -period_days day) as previous_start,
25 | timestampAdd(current_end, interval -period_days day) as previous_end
26 | SELECT
27 | current_start,
28 | current_end,
29 | previous_start,
30 | previous_end,
31 | period_days
32 |
33 | NODE current_period_data
34 | DESCRIPTION >
35 | Get page metrics for the current period
36 |
37 | SQL >
38 | %
39 | SELECT
40 | pathname,
41 | uniqMerge(visits) as current_visits,
42 | countMerge(hits) as current_hits
43 | FROM analytics_pages_mv
44 | WHERE date >= (SELECT current_start FROM date_calculations)
45 | AND date <= (SELECT current_end FROM date_calculations)
46 | {% if defined(tenant_id) %}
47 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
48 | {% end %}
49 | {% if defined(domain) %}
50 | AND domain = {{ String(domain, description="Filter by domain") }}
51 | {% end %}
52 | GROUP BY pathname
53 |
54 | NODE previous_period_data
55 | DESCRIPTION >
56 | Get page metrics for the previous period (only when include_previous_period is true)
57 |
58 | SQL >
59 | %
60 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
61 | SELECT
62 | pathname,
63 | uniqMerge(visits) as previous_visits,
64 | countMerge(hits) as previous_hits
65 | FROM analytics_pages_mv
66 | WHERE date >= (SELECT previous_start FROM date_calculations)
67 | AND date <= (SELECT previous_end FROM date_calculations)
68 | {% if defined(tenant_id) %}
69 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
70 | {% end %}
71 | {% if defined(domain) %}
72 | AND domain = {{ String(domain, description="Filter by domain") }}
73 | {% end %}
74 | GROUP BY pathname
75 | {% else %}
76 | SELECT '' as pathname, 0 as previous_visits, 0 as previous_hits WHERE 1=0
77 | {% end %}
78 |
79 | NODE endpoint
80 | DESCRIPTION >
81 | Combine current and previous period data with growth calculations
82 |
83 | SQL >
84 | %
85 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
86 | SELECT
87 | c.pathname,
88 | c.current_visits as visits,
89 | c.current_hits as hits,
90 | coalesce(p.previous_visits, 0) as previous_visits,
91 | coalesce(p.previous_hits, 0) as previous_hits,
92 | CASE
93 | WHEN coalesce(p.previous_visits, 0) = 0 AND c.current_visits > 0 THEN 100.0
94 | WHEN coalesce(p.previous_visits, 0) = 0 THEN 0.0
95 | ELSE round(((c.current_visits - coalesce(p.previous_visits, 0)) * 100.0) / p.previous_visits, 2)
96 | END as visits_growth_percentage,
97 | CASE
98 | WHEN coalesce(p.previous_hits, 0) = 0 AND c.current_hits > 0 THEN 100.0
99 | WHEN coalesce(p.previous_hits, 0) = 0 THEN 0.0
100 | ELSE round(((c.current_hits - coalesce(p.previous_hits, 0)) * 100.0) / p.previous_hits, 2)
101 | END as hits_growth_percentage
102 | FROM current_period_data c
103 | LEFT JOIN previous_period_data p ON c.pathname = p.pathname
104 | ORDER BY c.current_visits DESC
105 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
106 | {% else %}
107 | SELECT
108 | pathname,
109 | current_visits as visits,
110 | current_hits as hits
111 | FROM current_period_data
112 | ORDER BY current_visits DESC
113 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
114 | {% end %}
115 |
116 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/endpoints/top_sources.pipe:
--------------------------------------------------------------------------------
1 | DESCRIPTION >
2 | Top traffic sources (domains), ordered by most visits.
3 | Accepts `date_from` and `date_to` date filter. Defaults to last 7 days.
4 | Set `include_previous_period=true` to get previous period metrics and growth percentage.
5 | Also `skip` and `limit` parameters for pagination.
6 | Supports `tenant_id` and `domain` filtering.
7 |
8 | TOKEN "dashboard" READ
9 |
10 | NODE date_calculations
11 | DESCRIPTION >
12 | Calculate current and previous period date ranges
13 |
14 | SQL >
15 | %
16 | WITH
17 | {% if defined(date_from) and defined(date_to) %}
18 | toDate({{ String(date_from) }}) as current_start,
19 | toDate({{ String(date_to) }}) as current_end,
20 | {% else %}
21 | toDate(timestampAdd(today(), interval -7 day)) as current_start,
22 | toDate(today()) as current_end,
23 | {% end %}
24 | dateDiff('day', current_start, current_end) + 1 as period_days,
25 | timestampAdd(current_start, interval -period_days day) as previous_start,
26 | timestampAdd(current_end, interval -period_days day) as previous_end
27 | SELECT
28 | current_start,
29 | current_end,
30 | previous_start,
31 | previous_end,
32 | period_days
33 |
34 | NODE current_period_data
35 | DESCRIPTION >
36 | Get source metrics for the current period
37 |
38 | SQL >
39 | %
40 | SELECT
41 | domainWithoutWWW(referrer) as referrer,
42 | uniqMerge(visits) as current_visits,
43 | countMerge(hits) as current_hits
44 | FROM analytics_sources_mv
45 | WHERE date >= (SELECT current_start FROM date_calculations)
46 | AND date <= (SELECT current_end FROM date_calculations)
47 | {% if defined(tenant_id) %}
48 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
49 | {% end %}
50 | {% if defined(domain) %}
51 | AND domain = {{ String(domain, description="Filter by domain") }}
52 | {% end %}
53 | GROUP BY referrer
54 |
55 | NODE previous_period_data
56 | DESCRIPTION >
57 | Get source metrics for the previous period (only when include_previous_period is true)
58 |
59 | SQL >
60 | %
61 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
62 | SELECT
63 | domainWithoutWWW(referrer) as referrer,
64 | uniqMerge(visits) as previous_visits,
65 | countMerge(hits) as previous_hits
66 | FROM analytics_sources_mv
67 | WHERE date >= (SELECT previous_start FROM date_calculations)
68 | AND date <= (SELECT previous_end FROM date_calculations)
69 | {% if defined(tenant_id) %}
70 | AND tenant_id = {{ String(tenant_id, description="Filter by tenant ID") }}
71 | {% end %}
72 | {% if defined(domain) %}
73 | AND domain = {{ String(domain, description="Filter by domain") }}
74 | {% end %}
75 | GROUP BY referrer
76 | {% else %}
77 | SELECT '' as referrer, 0 as previous_visits, 0 as previous_hits WHERE 1=0
78 | {% end %}
79 |
80 | NODE endpoint
81 | DESCRIPTION >
82 | Combine current and previous period data with growth calculations
83 |
84 | SQL >
85 | %
86 | {% if defined(include_previous_period) and include_previous_period == 'true' %}
87 | SELECT
88 | c.referrer,
89 | c.current_visits as visits,
90 | c.current_hits as hits,
91 | coalesce(p.previous_visits, 0) as previous_visits,
92 | coalesce(p.previous_hits, 0) as previous_hits,
93 | CASE
94 | WHEN coalesce(p.previous_visits, 0) = 0 AND c.current_visits > 0 THEN 100.0
95 | WHEN coalesce(p.previous_visits, 0) = 0 THEN 0.0
96 | ELSE round(((c.current_visits - coalesce(p.previous_visits, 0)) * 100.0) / p.previous_visits, 2)
97 | END as visits_growth_percentage,
98 | CASE
99 | WHEN coalesce(p.previous_hits, 0) = 0 AND c.current_hits > 0 THEN 100.0
100 | WHEN coalesce(p.previous_hits, 0) = 0 THEN 0.0
101 | ELSE round(((c.current_hits - coalesce(p.previous_hits, 0)) * 100.0) / p.previous_hits, 2)
102 | END as hits_growth_percentage
103 | FROM current_period_data c
104 | LEFT JOIN previous_period_data p ON c.referrer = p.referrer
105 | ORDER BY c.current_visits DESC
106 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
107 | {% else %}
108 | SELECT
109 | referrer,
110 | current_visits as visits,
111 | current_hits as hits
112 | FROM current_period_data
113 | ORDER BY current_visits DESC
114 | LIMIT {{ Int32(skip, 0) }}, {{ Int32(limit, 50) }}
115 | {% end %}
116 |
117 | TYPE endpoint
--------------------------------------------------------------------------------
/tinybird/fixtures/analytics_events.sql:
--------------------------------------------------------------------------------
1 | WITH
2 | tenant_domains AS (
3 | SELECT
4 | tenant_id,
5 | domain
6 | FROM (
7 | SELECT
8 | 'tenant_1' AS tenant_id, 'example.com' AS domain UNION ALL
9 | SELECT 'tenant_2', 'myapp.com' UNION ALL
10 | SELECT 'tenant_3', 'demo.site' UNION ALL
11 | SELECT 'tenant_8', 'test.org' UNION ALL
12 | SELECT 'tenant_14', 'app.demo.io' UNION ALL
13 | SELECT 'tenant_16', 'example.com' UNION ALL
14 | SELECT 'tenant_17', 'demo.site' UNION ALL
15 | SELECT 'tenant_18', 'test.org' UNION ALL
16 | SELECT 'tenant_10', 'myapp.com' UNION ALL
17 | SELECT '', '' UNION ALL
18 | SELECT '', 'example.com' UNION ALL
19 | SELECT 'tenant_1', ''
20 | )
21 | ),
22 | versions AS (
23 | SELECT arrayJoin(['v1.0.0', 'v1.1.0', 'v2.0.0', 'v2.1.0', 'v3.0.0', 'v3.1.0', 'v3.2.1']) AS version
24 | ),
25 | pathnames AS (
26 | SELECT arrayJoin(['/', '/home', '/about', '/pricing', '/contact', '/blog', '/login', '/product']) AS pathname
27 | ),
28 | referrers AS (
29 | SELECT arrayJoin(['https://google.com', 'https://facebook.com', 'https://twitter.com', 'https://linkedin.com', '']) AS referrer
30 | ),
31 | user_agents AS (
32 | SELECT arrayJoin([
33 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
34 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15',
35 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1'
36 | ]) AS user_agent
37 | ),
38 | locales AS (
39 | SELECT arrayJoin(['en-US', 'fr-FR', 'de-DE', 'es-ES', 'it-IT', 'zh-CN']) AS locale
40 | ),
41 | countries AS (
42 | SELECT arrayJoin(['United States', 'Canada', 'United Kingdom', 'Germany', 'France', 'Spain', 'Italy', 'Japan', 'China']) AS country
43 | ),
44 | cities AS (
45 | SELECT arrayJoin(['New York', 'London', 'Paris', 'Berlin', 'Madrid', 'Tokyo', 'Beijing', 'Los Angeles', 'Boston']) AS city
46 | ),
47 | web_vital_names AS (
48 | SELECT arrayJoin(['LCP', 'TTFB', 'FCP', 'INP', 'CLS']) AS name
49 | )
50 | SELECT
51 | toDateTime('2025-07-17') - rand() % (86400 * 365) AS timestamp,
52 | case when rand() % 10 > 0 then concat('sessionid', toString(rand() % 900 + 100), '-', toString(rand() % 900 + 100), '-', toString(rand() % 900 + 100), '-abc-def', toString(rand() % 900000 + 100000)) else NULL end AS session_id,
53 | if(rand() % 2 = 0, 'page_hit', 'web_vital') AS action,
54 | (SELECT version FROM versions ORDER BY rand() LIMIT 1) AS version,
55 | if(
56 | rand() % 2 = 0,
57 | -- page_hit payload
58 | concat('{
59 | "pathname": "', p.pathname, '",
60 | "href": "https://', td.domain, p.pathname, '",
61 | "referrer": "', r.referrer, '",
62 | "userAgent": "', ua.user_agent, '",
63 | "locale": "', l.locale, '",
64 | "location": {
65 | "country": "', co.country, '",
66 | "city": "', ci.city, '"
67 | }
68 | }'),
69 | -- web_vital payload
70 | concat('{
71 | "name": "', wvn.name, '",
72 | "value": ',
73 | case
74 | when wvn.name = 'LCP' then toString(1500 + rand() % 3000)
75 | when wvn.name = 'TTFB' then toString(200 + rand() % 1000)
76 | when wvn.name = 'FCP' then toString(800 + rand() % 2400)
77 | when wvn.name = 'INP' then toString(100 + rand() % 500)
78 | when wvn.name = 'CLS' then toString(round(0.05 + rand() / 1000000000 * 0.3, 2))
79 | else '0'
80 | end,
81 | ',
82 | "delta": ', toString(50 + rand() % 150), ',
83 | "pathname": "', p.pathname, '",
84 | "domain": "', td.domain, '"
85 | }')
86 | ) AS payload,
87 | td.tenant_id AS tenant_id,
88 | td.domain AS domain
89 | FROM
90 | numbers(12000),
91 | (SELECT * FROM tenant_domains ORDER BY rand() LIMIT 1) AS td,
92 | (SELECT * FROM pathnames ORDER BY rand() LIMIT 1) AS p,
93 | (SELECT * FROM referrers ORDER BY rand() LIMIT 1) AS r,
94 | (SELECT * FROM user_agents ORDER BY rand() LIMIT 1) AS ua,
95 | (SELECT * FROM locales ORDER BY rand() LIMIT 1) AS l,
96 | (SELECT * FROM countries ORDER BY rand() LIMIT 1) AS co,
97 | (SELECT * FROM cities ORDER BY rand() LIMIT 1) AS ci,
98 | (SELECT * FROM web_vital_names ORDER BY rand() LIMIT 1) AS wvn
99 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/AIChatForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useRef } from 'react'
4 | import { useAIChat } from './AIChatProvider'
5 | import { FormatIcon } from '@/components/ui/Icons'
6 | import { motion, type MotionValue, useTime, useTransform } from 'motion/react'
7 | import { Card } from '../ui/Card'
8 | import { Text } from '../ui/Text'
9 |
10 | interface AIChatFormProps {
11 | placeholder?: string
12 | className?: string
13 | }
14 |
15 | const InputWrapper = ({
16 | children,
17 | isLoading,
18 | background,
19 | }: {
20 | children: React.ReactNode
21 | isLoading: boolean
22 | background: MotionValue
23 | }) => {
24 | if (isLoading) {
25 | return (
26 |
30 |
31 | {children}
32 |
33 |
34 | )
35 | }
36 |
37 | return (
38 |
39 | {children}
40 |
41 | )
42 | }
43 |
44 | export function AIChatForm({
45 | placeholder = 'Are there any bounce rate trends for my blog pages?',
46 | className = '',
47 | }: AIChatFormProps) {
48 | const {
49 | input,
50 | handleInputChange,
51 | handleSubmit,
52 | isLoading,
53 | error,
54 | setMessages,
55 | setInput,
56 | lastSubmittedQuestion,
57 | } = useAIChat()
58 |
59 | const messagesEndRef = useRef(null)
60 | const time = useTime()
61 |
62 | // Animated conic-gradient for loading border
63 | const background = useTransform(
64 | () =>
65 | `conic-gradient(from ${
66 | time.get() * 0.25
67 | }deg, var(--border-01-color), var(--border-03-color), var(--border-01-color))`
68 | )
69 |
70 | const onSubmit = (e: React.FormEvent) => {
71 | e.preventDefault()
72 | const savedInput = input // Save the current input value
73 |
74 | // Clear previous messages before submitting
75 | setMessages([])
76 |
77 | handleSubmit(e)
78 |
79 | // Restore the input value after submission to prevent clearing
80 | setTimeout(() => {
81 | setInput(savedInput)
82 | messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
83 | }, 10)
84 | }
85 |
86 | const isDisabled = !input.trim() || isLoading
87 |
88 | return (
89 |
104 |
141 |
142 | )
143 | }
144 |
--------------------------------------------------------------------------------
/dashboard/components/ai-chat/InsightCards.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useAIChat } from './AIChatProvider'
5 | import { Text } from '../ui/Text'
6 | import { Skeleton } from '../ui/Skeleton'
7 | import { cn } from '@/lib/utils'
8 | import {
9 | InsightIncreaseIcon,
10 | InsightDecreaseIcon,
11 | InsightTrendIcon,
12 | InsightListIcon,
13 | } from '../ui/Icons'
14 |
15 | export interface InsightCard {
16 | id: string
17 | title: string
18 | description: string
19 | question: string
20 | color?: 'blue' | 'green' | 'purple' | 'orange'
21 | metric?: string
22 | subtitle?: string
23 | isHighlighted?: boolean
24 | type?: 'chart' | 'list' | 'metric'
25 | isLoading?: boolean
26 | delta?: number
27 | }
28 |
29 | interface InsightCardsProps {
30 | insights: InsightCard[]
31 | className?: string
32 | onCardClick?: () => void
33 | isLoading?: boolean
34 | }
35 |
36 | export function InsightCards({
37 | insights,
38 | onCardClick,
39 | isLoading = false,
40 | }: InsightCardsProps) {
41 | const {
42 | setInput,
43 | handleSubmit,
44 | setLastSubmittedQuestion,
45 | input,
46 | setMessages,
47 | } = useAIChat()
48 |
49 | const handleCardClick = (question: string) => {
50 | // Clear previous messages for one-off questions
51 | setMessages([])
52 |
53 | // Set the last submitted question as placeholder first
54 | setLastSubmittedQuestion(question)
55 |
56 | // Set the input with the question
57 | setInput(question)
58 |
59 | // Open the modal if callback is provided
60 | if (onCardClick) {
61 | onCardClick()
62 | }
63 |
64 | // Submit after a delay to allow the modal to open and input to be set
65 | setTimeout(() => {
66 | // Try to trigger the form submission by finding the form and submitting it
67 | const form = document.querySelector('form')
68 | if (form) {
69 | form.dispatchEvent(
70 | new Event('submit', { bubbles: true, cancelable: true })
71 | )
72 | } else {
73 | const syntheticEvent = {
74 | preventDefault: () => {},
75 | } as React.FormEvent
76 |
77 | handleSubmit(syntheticEvent)
78 | }
79 | }, 200)
80 | }
81 |
82 | const getColorClasses = (insight: InsightCard) => {
83 | // Use blue border for highlighted cards, otherwise use default border
84 | if (insight.isHighlighted) {
85 | return 'bg-white border-2 border-[var(--text-blue-color)] hover:border-[var(--text-blue-color)]'
86 | }
87 | return 'bg-white border border-[var(--border-02-color)] hover:border-[var(--border-03-color)] focus:outline active:outline outline-[3px] outline-[var(--alternative-color)] outline-offset-[3px]'
88 | }
89 |
90 | return insights.map(insight => (
91 | handleCardClick(insight.question)}
94 | className={cn(
95 | '!aspect-square w-40 h-40 rounded-lg transition-all duration-100',
96 | 'flex flex-col justify-between p-[15px] items-start text-left',
97 | 'border-solid border-[var(--border-02-color)] focus:border- focus:border-[var(--border-03-color)]',
98 | getColorClasses(insight),
99 | isLoading && 'opacity-50 cursor-not-allowed'
100 | )}
101 | aria-label={`Ask: ${insight.title}`}
102 | disabled={isLoading}
103 | >
104 | {/* Icon placeholder */}
105 |
106 | {insight.type === 'list' ? (
107 |
108 | ) : insight.delta !== undefined ? (
109 | insight.delta > 0 ? (
110 |
111 | ) : (
112 |
113 | )
114 | ) : (
115 |
116 | )}
117 |
118 |
119 |
120 | {insight.isLoading ? (
121 | <>
122 |
123 |
124 | >
125 | ) : insight.metric ? (
126 | <>
127 |
131 | {insight.metric}
132 |
133 |
138 | {insight.subtitle || insight.description}
139 |
140 | >
141 | ) : (
142 | <>
143 |
147 | {insight.title}
148 |
149 |
154 | {insight.description}
155 |
156 | >
157 | )}
158 |
159 |
160 | ))
161 | }
162 |
--------------------------------------------------------------------------------