feat: update analytics

This commit is contained in:
ivannoskov
2026-01-12 12:46:29 +03:00
parent 52a4425751
commit 1f36a074af
14 changed files with 1683 additions and 594 deletions

View File

@@ -13,6 +13,8 @@ import type {
PaginatedResponse,
OverviewAnalytics,
OverviewAnalyticsQueryParams,
ProjectsAnalyticsResponse,
ProjectsAnalyticsQueryParams,
} from "@/lib/types/api";
export const analyticsApi = {
@@ -60,4 +62,13 @@ export const analyticsApi = {
`/workspaces/${workspaceId}/analytics/overview`,
{ params }
),
/**
* Get projects analytics (chart data with grouping)
*/
projects: (workspaceId: string, params: ProjectsAnalyticsQueryParams) =>
api.get<ProjectsAnalyticsResponse>(
`/workspaces/${workspaceId}/analytics/projects`,
{ params }
),
};

View File

@@ -3,6 +3,8 @@
// ============================================================================
// This wrapper checks if the workspace is demo and returns mock data instead
import { format, getISOWeek } from "date-fns";
import { ru } from "date-fns/locale";
import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants";
import {
demoChannels,
@@ -16,6 +18,7 @@ import {
demoWorkspace,
demoMember,
demoOverviewAnalytics,
demoPlacementAnalytics,
} from "./mock-data";
import type {
PaginatedResponse,
@@ -31,6 +34,9 @@ import type {
WorkspaceMember,
DateGrouping,
OverviewAnalytics,
PlacementAnalyticsItem,
ProjectsAnalyticsResponse,
ProjectsAnalyticsQueryParams,
} from "@/lib/types/api";
import {
workspacesApi,
@@ -349,6 +355,19 @@ export const demoAwareAnalyticsApi = {
}
return analyticsApi.channels(workspaceId, params);
},
placements: async (
workspaceId: string,
params?: { project_id?: string; page?: number; size?: number }
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
if (isDemoWorkspace(workspaceId)) {
let items = demoPlacementAnalytics;
if (params?.project_id) {
items = items.filter((p) => p.project_id === params.project_id);
}
return paginate(items, params?.page, params?.size);
}
return analyticsApi.placements(workspaceId, params);
},
overview: async (
workspaceId: string,
params?: { project_id?: string; date_from?: string; date_to?: string }
@@ -358,5 +377,145 @@ export const demoAwareAnalyticsApi = {
}
return analyticsApi.overview(workspaceId, params);
},
projects: async (
workspaceId: string,
params: ProjectsAnalyticsQueryParams
): Promise<ProjectsAnalyticsResponse> => {
if (isDemoWorkspace(workspaceId)) {
// Generate mock data based on demoPlacementAnalytics
const placements = demoPlacementAnalytics;
let filtered = placements;
if (params.project_ids && params.project_ids.length > 0) {
filtered = filtered.filter((p) => params.project_ids!.includes(p.project_id));
}
if (params.date_from) {
const from = new Date(params.date_from);
filtered = filtered.filter((p) => new Date(p.placement_date) >= from);
}
if (params.date_to) {
const to = new Date(params.date_to);
filtered = filtered.filter((p) => new Date(p.placement_date) <= to);
}
// Helper to format period label
const formatPeriodLabel = (period: string, grouping: string): string => {
try {
if (grouping === "day") {
const date = new Date(period + "T00:00:00");
return format(date, "d MMM", { locale: ru });
} else if (grouping === "week") {
return period.replace("W", " нед. ");
} else if (grouping === "month") {
const date = new Date(period + "-01");
return format(date, "MMM yyyy", { locale: ru });
} else if (grouping === "quarter") {
return period.replace("Q", "Q");
}
return period;
} catch {
return period;
}
};
// Group by period
const grouped: Record<string, typeof filtered> = {};
filtered.forEach((item) => {
const date = new Date(item.placement_date);
let periodKey: string;
switch (params.grouping) {
case "day":
periodKey = format(date, "yyyy-MM-dd");
break;
case "week":
const weekNum = getISOWeek(date);
periodKey = `${date.getFullYear()}-W${weekNum.toString().padStart(2, "0")}`;
break;
case "month":
periodKey = format(date, "yyyy-MM");
break;
case "quarter":
const quarter = Math.floor(date.getMonth() / 3) + 1;
periodKey = `${date.getFullYear()}-Q${quarter}`;
break;
default:
periodKey = format(date, "yyyy-MM-dd");
}
if (!grouped[periodKey]) {
grouped[periodKey] = [];
}
grouped[periodKey].push(item);
});
// Calculate metrics for each period
const periods = Object.entries(grouped)
.map(([period, items]) => {
const totalCost = items.reduce((sum, item) => sum + (item.cost || 0), 0);
const totalSubs = items.reduce((sum, item) => sum + (item.subscriptions_count || 0), 0);
const totalViews = items.reduce((sum, item) => sum + (item.views_count || 0), 0);
const purchasesCount = items.length;
return {
period,
period_label: formatPeriodLabel(period, params.grouping),
metrics: {
total_cost: totalCost,
purchases_count: purchasesCount,
total_subscriptions: totalSubs,
total_views: totalViews,
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
avg_post_cost: purchasesCount > 0 ? totalCost / purchasesCount : null,
clicks_count: totalSubs, // Using subscriptions as proxy
reach_volume: totalViews,
total_discounts: 0,
avg_discount_percent: 0,
avg_conversion: 0,
},
};
})
.sort((a, b) => a.period.localeCompare(b.period));
// Calculate totals
const totals = periods.reduce(
(acc, period) => {
acc.total_cost += period.metrics.total_cost;
acc.purchases_count += period.metrics.purchases_count;
acc.total_subscriptions += period.metrics.total_subscriptions;
acc.total_views += period.metrics.total_views;
acc.clicks_count += period.metrics.clicks_count;
acc.reach_volume += period.metrics.reach_volume;
acc.total_discounts += period.metrics.total_discounts || 0;
return acc;
},
{
total_cost: 0,
purchases_count: 0,
total_subscriptions: 0,
total_views: 0,
clicks_count: 0,
reach_volume: 0,
total_discounts: 0,
}
);
return {
periods,
totals: {
...totals,
avg_cpf: totals.total_subscriptions > 0 ? totals.total_cost / totals.total_subscriptions : null,
avg_cpm: totals.total_views > 0 ? (totals.total_cost / totals.total_views) * 1000 : null,
avg_post_cost: totals.purchases_count > 0 ? totals.total_cost / totals.purchases_count : null,
avg_discount_percent: 0,
avg_conversion: 0,
},
};
}
return analyticsApi.projects(workspaceId, params);
},
};

View File

@@ -15,6 +15,7 @@ import type {
CreativeAnalyticsItem,
ChannelAnalyticsItem,
OverviewAnalytics,
PlacementAnalyticsItem,
} from "@/lib/types/api";
import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants";
@@ -590,6 +591,85 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [
},
];
// Placements Analytics (Projects Analytics)
export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
{
id: "plac-0001-0000-0000-000000000001",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0001-0000-0000-000000000001",
placement_channel_title: "Технологии Будущего",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
placement_date: "2024-12-20T12:00:00Z",
cost: 5500,
subscriptions_count: 156,
views_count: 8500,
cpf: 35.26,
cpm: 647.06,
},
{
id: "plac-0002-0000-0000-000000000002",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0002-0000-0000-000000000002",
placement_channel_title: "Инвестиции Pro",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
placement_date: "2024-12-18T14:00:00Z",
cost: 3200,
subscriptions_count: 89,
views_count: 4200,
cpf: 35.96,
cpm: 761.9,
},
{
id: "plac-0003-0000-0000-000000000003",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0003-0000-0000-000000000003",
placement_channel_title: "Программисты",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
placement_date: "2024-12-15T10:00:00Z",
cost: 4800,
subscriptions_count: 234,
views_count: 12000,
cpf: 20.51,
cpm: 400.0,
},
{
id: "plac-0004-0000-0000-000000000004",
project_id: "proj-0002-0000-0000-000000000002",
project_channel_title: "IT Вакансии",
placement_channel_id: "chan-0004-0000-0000-000000000004",
placement_channel_title: "Маркетинг Pro",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Крипто - Агрессивный",
placement_date: "2024-12-10T16:00:00Z",
cost: 2500,
subscriptions_count: 67,
views_count: 3100,
cpf: 37.31,
cpm: 806.45,
},
{
id: "plac-0005-0000-0000-000000000005",
project_id: "proj-0002-0000-0000-000000000002",
project_channel_title: "IT Вакансии",
placement_channel_id: "chan-0005-0000-0000-000000000005",
placement_channel_title: "Финансовая грамотность",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Крипто - Агрессивный",
placement_date: "2024-12-05T11:00:00Z",
cost: 6200,
subscriptions_count: 312,
views_count: 15600,
cpf: 19.87,
cpm: 397.44,
},
];
export const demoOverviewAnalytics: OverviewAnalytics = {
total_cost: { value: 523400.5, delta_percent: 12.4 },
total_reach: { value: 380000, delta_percent: -8.1 },

View File

@@ -291,18 +291,21 @@ export interface ViewsHistoryQueryParams extends PaginationParams {
export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH";
// Placements Analytics
// Placements Analytics (Projects Analytics)
export interface PlacementAnalyticsItem {
placement_id: string;
id: string; // UUID
project_id: string; // UUID
project_channel_title: string;
placement_channel_id: string; // UUID
placement_channel_title: string;
placement_channel_username: string | null;
creative_id: string; // UUID
creative_name: string;
placement_date: string;
cost: number | null;
placement_date: string; // ISO 8601
cost: number;
subscriptions_count: number;
views_count: number | null;
cps: number | null; // cost per subscription
cpm: number | null; // cost per mille views
views_count: number;
cpf: number;
cpm: number;
}
// Creatives Analytics (matches actual API response)
@@ -353,6 +356,42 @@ export interface AnalyticsQueryParams extends PaginationParams {
project_id?: string;
}
// Projects Analytics Chart API
export interface ProjectsAnalyticsMetrics {
total_cost: number;
purchases_count: number;
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
avg_post_cost: number | null;
clicks_count: number;
reach_volume: number;
total_discounts: number | null;
avg_discount_percent: number | null;
avg_conversion: number | null;
}
export interface ProjectsAnalyticsPeriod {
period: string;
period_label: string;
metrics: ProjectsAnalyticsMetrics;
}
export interface ProjectsAnalyticsResponse {
periods: ProjectsAnalyticsPeriod[];
totals: ProjectsAnalyticsMetrics;
}
export interface ProjectsAnalyticsQueryParams {
project_ids?: string[];
date_from?: string;
date_to?: string;
grouping: "day" | "week" | "month" | "quarter";
date_grouping: "purchase_date" | "link_date" | "placement_date";
metrics?: string[];
}
export interface SpendingAnalyticsQueryParams {
project_id?: string;
date_from?: string;

View File

@@ -67,11 +67,13 @@ export const buildRoute = {
`/dashboard/${workspaceId}/placements/${placementId}`,
// Analytics
analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics`,
analyticsSpending: (workspaceId: string) =>
`/dashboard/${workspaceId}/analytics/spending`,
analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics/projects`,
analyticsProjects: (workspaceId: string) =>
`/dashboard/${workspaceId}/analytics/projects`,
analyticsCreatives: (workspaceId: string) =>
`/dashboard/${workspaceId}/analytics/creatives`,
analyticsSpending: (workspaceId: string) =>
`/dashboard/${workspaceId}/analytics/spending`,
analyticsChannels: (workspaceId: string) =>
`/dashboard/${workspaceId}/analytics/channels`,
analyticsPlacements: (workspaceId: string) =>