diff --git a/apps/client/src/ee/audit/pages/audit-logs.tsx b/apps/client/src/ee/audit/pages/audit-logs.tsx index 05f7881ad..c7d89a129 100644 --- a/apps/client/src/ee/audit/pages/audit-logs.tsx +++ b/apps/client/src/ee/audit/pages/audit-logs.tsx @@ -26,24 +26,8 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; import useUserRole from "@/hooks/use-user-role"; +import { daysToRetention, retentionToDays, RetentionUnit } from "@/ee/utils"; -type RetentionUnit = "days" | "months" | "years"; - -function daysToRetention(days: number): { amount: number; unit: RetentionUnit } { - if (days >= 365 && days % 365 === 0) { - return { amount: days / 365, unit: "years" }; - } - if (days >= 30 && days % 30 === 0) { - return { amount: days / 30, unit: "months" }; - } - return { amount: days, unit: "days" }; -} - -function retentionToDays(amount: number, unit: RetentionUnit): number { - if (unit === "years") return amount * 365; - if (unit === "months") return amount * 30; - return amount; -} export default function AuditLogs() { const { t } = useTranslation(); diff --git a/apps/client/src/ee/page-analytics/pages/page-analytics.tsx b/apps/client/src/ee/page-analytics/pages/page-analytics.tsx index a7dfd4505..2a1126305 100644 --- a/apps/client/src/ee/page-analytics/pages/page-analytics.tsx +++ b/apps/client/src/ee/page-analytics/pages/page-analytics.tsx @@ -1,13 +1,18 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { + ActionIcon, Badge, + Button, Card, Group, + NumberInput, + Popover, Select, SimpleGrid, Stack, Table, Text, + Tooltip, } from "@mantine/core"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; @@ -16,34 +21,59 @@ import { getAppName } from "@/lib/config"; import Paginate from "@/components/common/paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { + usePageAnalyticsRetentionQuery, + useUpdatePageAnalyticsRetentionMutation, useWorkspacePageAnalyticsDailyStatsQuery, useWorkspacePageAnalyticsTopPagesQuery, useWorkspacePageAnalyticsTotalsQuery, } from "@/ee/page-analytics/queries/page-analytics-query"; import { Link } from "react-router-dom"; import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale"; +import { IconSettings } from "@tabler/icons-react"; +import { + daysToRetention, + formatNumber, + retentionToDays, + RetentionUnit, + toISODate, +} from "@/ee/utils"; type RangePreset = "7" | "30" | "90"; const DAILY_PAGE_SIZE = 10; -function toISODate(daysAgo: number | string): string { - const daysNum = Number(daysAgo); - - return new Date(Date.now() - daysNum * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); -} - -function formatNumber(value: number | null | undefined): string { - return Number(value ?? 0).toLocaleString(); -} - export default function PageAnalytics() { const { t } = useTranslation(); const locale = useDateFnsLocale(); const [rangePreset, setRangePreset] = useState("30"); const [topPagesLimit, setTopPagesLimit] = useState("10"); + const [settingsOpen, setSettingsOpen] = useState(false); + + const { data: retentionData } = usePageAnalyticsRetentionQuery(); + const updateRetention = useUpdatePageAnalyticsRetentionMutation(); + + const parsed = useMemo( + () => daysToRetention(retentionData?.retentionDays ?? 365), + [retentionData] + ); + + useEffect(() => { + if (settingsOpen) return; + + setRetentionAmount(parsed.amount); + setRetentionUnit(parsed.unit); + }, [parsed]); + + const [retentionAmount, setRetentionAmount] = useState( + parsed.amount + ); + const [retentionUnit, setRetentionUnit] = useState(parsed.unit); + + const resetRetentionForm = useCallback(() => { + const { amount, unit } = daysToRetention(retentionData?.retentionDays ?? 365); + setRetentionAmount(amount); + setRetentionUnit(unit); + }, [setRetentionAmount, setRetentionUnit, retentionData]); const { cursor: topPagesCursor, @@ -129,9 +159,6 @@ export default function PageAnalytics() { - - {t("Unique visitors and view volume for your workspace pages.")} - { + if (value === "days" || value === "months" || value === "years") { + setRetentionUnit(value); + } + }} + size="sm" + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + + + + diff --git a/apps/client/src/ee/page-analytics/queries/page-analytics-query.ts b/apps/client/src/ee/page-analytics/queries/page-analytics-query.ts index fefa3dfe9..52c9a1707 100644 --- a/apps/client/src/ee/page-analytics/queries/page-analytics-query.ts +++ b/apps/client/src/ee/page-analytics/queries/page-analytics-query.ts @@ -1,8 +1,10 @@ -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + getPageAnalyticsRetention, getWorkspacePageAnalyticsDailyStats, getWorkspacePageAnalyticsTopPages, getWorkspacePageAnalyticsTotals, + updatePageAnalyticsRetention, } from "@/ee/page-analytics/services/page-analytics-service"; import type { IPagination } from "@/lib/types"; import type { @@ -12,6 +14,8 @@ import type { WorkspaceAnalyticsTopPage, WorkspaceAnalyticsTotals, } from "@/ee/page-analytics/types/page-analytics.types"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; export function useWorkspacePageAnalyticsTotalsQuery( params?: WorkspaceAnalyticsParams, @@ -42,3 +46,28 @@ export function useWorkspacePageAnalyticsTopPagesQuery( placeholderData: keepPreviousData, }); } + +export function usePageAnalyticsRetentionQuery() { + return useQuery({ + queryKey: ["page-analytics-retention"], + queryFn: () => getPageAnalyticsRetention(), + }); +} + +export function useUpdatePageAnalyticsRetentionMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data: { pageAnalyticsRetentionDays: number }) => + updatePageAnalyticsRetention(data), + onSuccess: () => { + notifications.show({ message: t("Page analytics retention updated") }); + queryClient.invalidateQueries({ queryKey: ["page-analytics-retention"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} diff --git a/apps/client/src/ee/page-analytics/services/page-analytics-service.ts b/apps/client/src/ee/page-analytics/services/page-analytics-service.ts index 4ddb8a1d2..79a2c6505 100644 --- a/apps/client/src/ee/page-analytics/services/page-analytics-service.ts +++ b/apps/client/src/ee/page-analytics/services/page-analytics-service.ts @@ -28,3 +28,16 @@ export async function getWorkspacePageAnalyticsTopPages( const req = await api.post("/page-analytics/workspace-top-pages", params); return req.data; } + +export async function getPageAnalyticsRetention(): Promise<{ retentionDays: number }> { + const req = await api.post("/page-analytics/retention"); + return req.data; +} + +export async function updatePageAnalyticsRetention(data: { + pageAnalyticsRetentionDays: number; +}): Promise<{ retentionDays: number }> { + + const req = await api.post("/page-analytics/retention/update", data); + return req.data; +} \ No newline at end of file diff --git a/apps/client/src/ee/utils.ts b/apps/client/src/ee/utils.ts index cac112d9d..ff51d00e4 100644 --- a/apps/client/src/ee/utils.ts +++ b/apps/client/src/ee/utils.ts @@ -14,3 +14,33 @@ export function exchangeTokenRedirectUrl( ) { return getHostnameUrl(hostname) + "/api/auth/exchange?token=" + exchangeToken; } + +export type RetentionUnit = "days" | "months" | "years"; + +export function daysToRetention(days: number): { amount: number; unit: RetentionUnit } { + if (days >= 365 && days % 365 === 0) { + return { amount: days / 365, unit: "years" }; + } + if (days >= 30 && days % 30 === 0) { + return { amount: days / 30, unit: "months" }; + } + return { amount: days, unit: "days" }; +} + +export function retentionToDays(amount: number, unit: RetentionUnit): number { + if (unit === "years") return amount * 365; + if (unit === "months") return amount * 30; + return amount; +} + +export function toISODate(daysAgo: number | string): string { + const daysNum = Number(daysAgo); + + return new Date(Date.now() - daysNum * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); +} + +export function formatNumber(value: number | null | undefined): string { + return Number(value ?? 0).toLocaleString(); +} \ No newline at end of file diff --git a/apps/server/src/database/migrations/20260809T000000-page-analytics.ts b/apps/server/src/database/migrations/20260809T000000-page-analytics.ts index 185d4131b..02526ce8f 100644 --- a/apps/server/src/database/migrations/20260809T000000-page-analytics.ts +++ b/apps/server/src/database/migrations/20260809T000000-page-analytics.ts @@ -47,8 +47,18 @@ export async function up(db: Kysely): Promise { COALESCE(user_id::text, visitor_id) ) `.execute(db); + + await db.schema + .alterTable('workspaces') + .addColumn('page_analytics_retention_days', 'int8', (col) => col) + .execute(); } export async function down(db: Kysely): Promise { + await db.schema + .alterTable('workspaces') + .dropColumn('page_analytics_retention_days') + .execute(); + await db.schema.dropTable('page_analytics').ifExists().execute(); } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index bb58e310f..fa4896e71 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -437,6 +437,7 @@ export interface WorkspaceInvitations { } export interface Workspaces { + pageAnalyticsRetentionDays: Generated; auditRetentionDays: Generated; trashRetentionDays: Generated; billingEmail: string | null;