Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions public/intl/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@
"sms": "SMS",
"source": "Source",
"sources": "Sources",
"sort-by": "Sort by",
"start-step": "Start Step",
"steps": "Steps",
"sum": "Sum",
Expand Down Expand Up @@ -330,6 +331,7 @@
"toggle-charts": "Toggle charts",
"total": "Total",
"total-records": "Total records",
"results-per-page": "Results per page",
"tracking-code": "Tracking code",
"traffic": "Traffic",
"transactions": "Transactions",
Expand Down Expand Up @@ -372,6 +374,8 @@
"visit-duration": "Visit duration",
"visitors": "Visitors",
"visits": "Visits",
"most-visitors-today": "Most visitors today",
"most-views-today": "Most views today",
"website": "Website",
"website-id": "Website ID",
"websites": "Websites",
Expand Down
118 changes: 113 additions & 5 deletions src/app/(main)/websites/WebsitesDataTable.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import { Icon, Row } from '@umami/react-zen';
import { Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
import Link from 'next/link';
import { DataGrid } from '@/components/common/DataGrid';
import { useLoginQuery, useNavigation, useUserWebsitesQuery } from '@/components/hooks';
import {
useLoginQuery,
useMessages,
useNavigation,
useTimezone,
useUserWebsitesQuery,
} from '@/components/hooks';
import { DEFAULT_PAGE_SIZE } from '@/lib/constants';
import { Favicon } from '@/index';
import { WebsitesTable } from './WebsitesTable';

const PAGE_SIZE_OPTIONS = [20, 50, 100];
const SORT_OPTIONS = [
{
id: 'visitors',
orderBy: 'visitors',
sortDescending: true,
},
{
id: 'pageviews',
orderBy: 'pageviews',
sortDescending: true,
},
{
id: 'name',
orderBy: 'name',
sortDescending: false,
},
{
id: 'domain',
orderBy: 'domain',
sortDescending: false,
},
] as const;

function getSelectedSort(orderBy?: string, sortDescending?: boolean | string) {
const isDescending = sortDescending === true || sortDescending === 'true';

return (
SORT_OPTIONS.find(
option => option.orderBy === orderBy && option.sortDescending === isDescending,
) || SORT_OPTIONS[0]
);
}

export function WebsitesDataTable({
userId,
teamId,
Expand All @@ -19,8 +60,20 @@ export function WebsitesDataTable({
showActions?: boolean;
}) {
const { user } = useLoginQuery();
const queryResult = useUserWebsitesQuery({ userId: userId || user?.id, teamId });
const { renderUrl } = useNavigation();
const { t, labels } = useMessages();
const { renderUrl, router, query, updateParams } = useNavigation();
const { timezone, canonicalizeTimezone } = useTimezone();
const pageSize = Number(query.pageSize) || DEFAULT_PAGE_SIZE;
const selectedSort = getSelectedSort(query.orderBy, query.sortDescending);
const queryResult = useUserWebsitesQuery(
{ userId: userId || user?.id, teamId },
{
pageSize,
orderBy: selectedSort.orderBy,
sortDescending: selectedSort.sortDescending,
timezone: canonicalizeTimezone(timezone),
},
);

const renderLink = (row: any) => (
<Row alignItems="center" gap="3">
Expand All @@ -31,8 +84,63 @@ export function WebsitesDataTable({
</Row>
);

const handlePageSizeChange = (value: string) => {
router.push(updateParams({ page: 1, pageSize: value }));
};

const handleSortChange = (value: string) => {
const sort = SORT_OPTIONS.find(option => option.id === value) || SORT_OPTIONS[0];
const isDefault = sort.id === SORT_OPTIONS[0].id;

router.push(
updateParams({
page: 1,
orderBy: isDefault ? undefined : sort.orderBy,
sortDescending: isDefault ? undefined : sort.sortDescending ? 'true' : 'false',
}),
);
Comment thread
Yashh56 marked this conversation as resolved.
};

const renderActions = () => (
<Row alignItems="center" gap="3" wrap="wrap">
<Row alignItems="center" gap="2">
<Text size="sm" color="muted">
{t(labels.sortBy)}
</Text>
<Select
value={selectedSort.id}
onChange={handleSortChange}
style={{ width: 220 }}
popoverProps={{ placement: 'bottom right' }}
>
<ListItem id="visitors">{t(labels.mostVisitorsToday)}</ListItem>
<ListItem id="pageviews">{t(labels.mostViewsToday)}</ListItem>
<ListItem id="name">{`${t(labels.name)} (A-Z)`}</ListItem>
<ListItem id="domain">{`${t(labels.domain)} (A-Z)`}</ListItem>
</Select>
</Row>
<Row alignItems="center" gap="2">
<Text size="sm" color="muted">
{t(labels.resultsPerPage)}
</Text>
<Select
value={pageSize.toString()}
onChange={handlePageSizeChange}
style={{ width: 120 }}
popoverProps={{ placement: 'bottom right' }}
>
{PAGE_SIZE_OPTIONS.map(value => (
<ListItem key={value} id={value.toString()}>
{value.toLocaleString()}
</ListItem>
))}
</Select>
</Row>
</Row>
);

return (
<DataGrid query={queryResult} allowSearch allowPaging>
<DataGrid query={queryResult} allowSearch allowPaging renderActions={renderActions}>
{({ data }) => (
<WebsitesTable
data={data}
Expand Down
5 changes: 4 additions & 1 deletion src/app/api/me/websites/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams, timezoneParam } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma';

export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
timezone: timezoneParam.optional(),
includeTeams: z.string().optional(),
});

Expand Down
4 changes: 3 additions & 1 deletion src/app/api/teams/[teamId]/websites/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams, timezoneParam } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamWebsites } from '@/queries/prisma';

export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
timezone: timezoneParam.optional(),
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
Expand Down
4 changes: 3 additions & 1 deletion src/app/api/users/[userId]/websites/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams, timezoneParam } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';

export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
timezone: timezoneParam.optional(),
includeTeams: z.string().optional(),
});

Expand Down
4 changes: 3 additions & 1 deletion src/app/api/websites/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams, timezoneParam } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
Expand All @@ -15,6 +15,8 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
timezone: timezoneParam.optional(),
includeTeams: z.string().optional(),
});

Expand Down
4 changes: 4 additions & 0 deletions src/components/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ export const labels: Record<string, string> = {
month: 'label.month',
date: 'label.date',
pageOf: 'label.page-of',
sortBy: 'label.sort-by',
resultsPerPage: 'label.results-per-page',
mostVisitorsToday: 'label.most-visitors-today',
mostViewsToday: 'label.most-views-today',
create: 'label.create',
search: 'label.search',
numberOfRecords: 'label.number-of-records',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export const pagingParams = {

export const sortingParams = {
orderBy: z.string().optional(),
sortDescending: z
.enum(['true', 'false'])
.transform(value => value === 'true')
.optional(),
};
Comment thread
Yashh56 marked this conversation as resolved.

export const userRoleParam = z.enum(['admin', 'user', 'view-only']);
Expand Down
106 changes: 105 additions & 1 deletion src/queries/prisma/website.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,107 @@
import { endOfDay, startOfDay } from 'date-fns';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
import type { Prisma, Website } from '@/generated/prisma/client';
import { DEFAULT_PAGE_SIZE } from '@/lib/constants';
import { normalizeTimezone } from '@/lib/date';
import { ROLES } from '@/lib/constants';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import type { QueryFilters } from '@/lib/types';
import { getWebsiteListStats } from '@/queries/sql';

const ACTIVITY_ORDER_FIELDS = ['pageviews', 'visitors'] as const;

function isValidDate(value?: Date) {
return value instanceof Date && !Number.isNaN(value.getTime());
}

function isActivityOrderBy(orderBy?: string): orderBy is (typeof ACTIVITY_ORDER_FIELDS)[number] {
return ACTIVITY_ORDER_FIELDS.includes(orderBy as (typeof ACTIVITY_ORDER_FIELDS)[number]);
}

function getActivityDateRange(filters: QueryFilters = {}) {
if (isValidDate(filters.startDate) && isValidDate(filters.endDate)) {
return {
startDate: filters.startDate,
endDate: filters.endDate,
};
}

const timezone =
filters.timezone && filters.timezone.toLowerCase() !== 'utc'
? normalizeTimezone(filters.timezone)
: 'UTC';
const zonedNow = utcToZonedTime(new Date(), timezone);

return {
startDate: zonedTimeToUtc(startOfDay(zonedNow), timezone),
endDate: zonedTimeToUtc(endOfDay(zonedNow), timezone),
};
}

async function getWebsitesByActivity(
criteria: Prisma.WebsiteFindManyArgs,
filters: QueryFilters,
orderBy: (typeof ACTIVITY_ORDER_FIELDS)[number],
) {
const { page = 1, pageSize, sortDescending = true, search } = filters;
const size = +pageSize || DEFAULT_PAGE_SIZE;
const websites = await prisma.client.website.findMany(criteria);
const count = websites.length;
Comment thread
Yashh56 marked this conversation as resolved.
Outdated

if (count === 0) {
return attachShareIdToWebsites({
data: [],
count,
page: +page,
pageSize: size,
orderBy,
search,
sortDescending,
});
}

const { startDate, endDate } = getActivityDateRange(filters);
const stats = await getWebsiteListStats(
websites.map(website => website.id),
{
startDate,
endDate,
},
);
const statsByWebsiteId = new Map(
stats.map(stat => [
stat.websiteId,
{
pageviews: Number(stat.pageviews) || 0,
visitors: Number(stat.visitors) || 0,
},
]),
);
const direction = sortDescending ? -1 : 1;
const data = [...websites]
.sort((a, b) => {
const aValue = statsByWebsiteId.get(a.id)?.[orderBy] || 0;
const bValue = statsByWebsiteId.get(b.id)?.[orderBy] || 0;

if (aValue !== bValue) {
return (aValue - bValue) * direction;
}

return a.name.localeCompare(b.name);
})
.slice(size * (+page - 1), size * (+page - 1) + size);

return attachShareIdToWebsites({
data,
count,
page: +page,
pageSize: size,
orderBy,
search,
sortDescending,
});
}

export async function findWebsite(criteria: Prisma.WebsiteFindUniqueArgs) {
return prisma.client.website.findUnique(criteria);
Expand All @@ -23,7 +122,7 @@ export async function getWebsite(websiteId: string) {
}

export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
const { search } = filters;
const { orderBy, search } = filters;
const { getSearchParameters, pagedQuery } = prisma;

const where: Prisma.WebsiteWhereInput = {
Expand All @@ -37,6 +136,10 @@ export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters:
deletedAt: null,
};

if (isActivityOrderBy(orderBy)) {
return getWebsitesByActivity({ ...criteria, where }, filters, orderBy);
}

const websites = await pagedQuery('website', { ...criteria, where }, filters);

return attachShareIdToWebsites(websites);
Expand Down Expand Up @@ -291,6 +394,7 @@ export async function attachShareIdToWebsites(websites: {
pageSize: number;
orderBy: string;
search: string;
sortDescending?: boolean;
}) {
const websiteIds = websites.data.map(website => website.id);

Expand Down
Loading