diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f158bc4..214689b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { FormAgentSessionsModule } from "./domains/agents/form-agent-sessions/fo import { AgentMessageFeedbackModule } from "./domains/agents/shared/agent-session-messages/feedback/agent-message-feedback.module" import { StreamingModule } from "./domains/agents/shared/agent-session-messages/streaming/streaming.module" import { InvitationsModule } from "./domains/agents/shared/memberships/invitations.module" +import { ProjectsAnalyticsModule } from "./domains/analytics/projects-analytics/projects-analytics.module" import { AuthModule } from "./domains/auth/auth.module" import { DocumentsModule } from "./domains/documents/documents.module" import { StorageModule } from "./domains/documents/storage/storage.module" @@ -46,6 +47,7 @@ import { UsersModule } from "./domains/users/users.module" OrganizationsModule, ProjectsModule, ProjectsModule, + ProjectsAnalyticsModule, StorageModule, StreamingModule, UsersModule, diff --git a/apps/api/src/domains/analytics/projects-analytics/e2e-tests/auth.spec.ts b/apps/api/src/domains/analytics/projects-analytics/e2e-tests/auth.spec.ts new file mode 100644 index 0000000..369ef03 --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/e2e-tests/auth.spec.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "node:crypto" +import { AnalyticsRoutes } from "@caseai-connect/api-contracts" +import type { INestApplication } from "@nestjs/common" +import type { App } from "supertest/types" +import { AUTH_ERRORS } from "@/common/errors/auth-errors" +import { clearTestDatabase, RandomUuid } from "@/common/test/test-database" +import { + type AllRepositories, + setupTransactionalTestDatabase, + teardownTestDatabase, +} from "@/common/test/test-transaction-manager" +import { removeNullish } from "@/common/utils/remove-nullish" +import { createOrganizationWithProject } from "@/domains/organizations/organization.factory" +import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers" +import { expectResponse, type Requester, testRequester } from "../../../../../test/request" +import { ProjectsAnalyticsModule } from "../projects-analytics.module" + +describe("Projects Analytics - Auth", () => { + let app: INestApplication + let request: Requester + let setup: Awaited> + let repositories: AllRepositories + + let organizationId: string | null = RandomUuid.Organization + let projectId: string | null = RandomUuid.Project + let accessToken: string | null = "token" + let auth0Id = "auth0|123" + + const dateRange = { + startAt: new Date("2026-01-01T00:00:00.000Z").getTime(), + endAt: new Date("2026-01-03T23:59:59.999Z").getTime(), + } + + beforeAll(async () => { + setup = await setupTransactionalTestDatabase({ + additionalImports: [ProjectsAnalyticsModule], + applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id), + }) + repositories = setup.getAllRepositories() + app = setup.module.createNestApplication() + await app.init() + request = testRequester(app) + }) + + beforeEach(async () => { + await clearTestDatabase(setup.dataSource) + organizationId = RandomUuid.Organization + projectId = RandomUuid.Project + accessToken = "token" + auth0Id = "auth0|123" + }) + + afterAll(async () => { + await teardownTestDatabase(setup) + await app.close() + }) + + const createContextForRole = async (role: "owner" | "admin" | "member" = "owner") => { + const { organization, project, user } = await createOrganizationWithProject(repositories, { + projectMembership: { role }, + }) + organizationId = organization.id + projectId = project.id + accessToken = "token" + auth0Id = user.auth0Id + return { organization, project, user } + } + + const analyticsDateRangeQuery = { + startAt: String(dateRange.startAt), + endAt: String(dateRange.endAt), + } + + const subjectConversations = async () => + request({ + route: AnalyticsRoutes.getConversationsPerDay, + pathParams: removeNullish({ organizationId, projectId }), + token: accessToken ?? undefined, + query: analyticsDateRangeQuery, + }) + + const subjectAvg = async () => + request({ + route: AnalyticsRoutes.getAvgUserQuestionsPerSessionPerDay, + pathParams: removeNullish({ organizationId, projectId }), + token: accessToken ?? undefined, + query: analyticsDateRangeQuery, + }) + + it("requires an authentication token", async () => { + accessToken = null + expectResponse(await subjectConversations(), 401, AUTH_ERRORS.NO_ACCESS_TOKEN) + expectResponse(await subjectAvg(), 401, AUTH_ERRORS.NO_ACCESS_TOKEN) + }) + + it("requires a valid organization ID", async () => { + organizationId = null + expectResponse(await subjectConversations(), 400, AUTH_ERRORS.NO_ORGANIZATION_ID) + expectResponse(await subjectAvg(), 400, AUTH_ERRORS.NO_ORGANIZATION_ID) + }) + + it("requires the user to be a member of the organization", async () => { + await createContextForRole("owner") + auth0Id = "auth0|456" + expectResponse(await subjectConversations(), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG) + expectResponse(await subjectAvg(), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG) + }) + + it("allows admins to list analytics for a project", async () => { + await createContextForRole("admin") + expectResponse(await subjectConversations(), 200) + expectResponse(await subjectAvg(), 200) + }) + + it("doesn't allow owners to list analytics for a project", async () => { + await createContextForRole("owner") + expectResponse(await subjectConversations(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + expectResponse(await subjectAvg(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + }) + + it("doesn't allow members to list analytics for a project", async () => { + await createContextForRole("member") + expectResponse(await subjectConversations(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + expectResponse(await subjectAvg(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + }) + + it("requires an existing project ID", async () => { + await createContextForRole("owner") + projectId = randomUUID() + expectResponse(await subjectConversations(), 404) + expectResponse(await subjectAvg(), 404) + }) +}) diff --git a/apps/api/src/domains/analytics/projects-analytics/e2e-tests/get-avg-user-questions-per-session-per-day.spec.ts b/apps/api/src/domains/analytics/projects-analytics/e2e-tests/get-avg-user-questions-per-session-per-day.spec.ts new file mode 100644 index 0000000..83ccc23 --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/e2e-tests/get-avg-user-questions-per-session-per-day.spec.ts @@ -0,0 +1,127 @@ +import { AnalyticsRoutes } from "@caseai-connect/api-contracts" +import type { INestApplication } from "@nestjs/common" +import type { App } from "supertest/types" +import { clearTestDatabase } from "@/common/test/test-database" +import { + type AllRepositories, + setupTransactionalTestDatabase, + teardownTestDatabase, +} from "@/common/test/test-transaction-manager" +import { removeNullish } from "@/common/utils/remove-nullish" +import { agentFactory } from "@/domains/agents/agent.factory" +import { conversationAgentSessionFactory } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.factory" +import { agentMessageFactory } from "@/domains/agents/shared/agent-session-messages/agent-messages.factory" +import { createOrganizationWithProject } from "@/domains/organizations/organization.factory" +import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers" +import { expectResponse, type Requester, testRequester } from "../../../../../test/request" +import { ProjectsAnalyticsModule } from "../projects-analytics.module" + +describe("Projects Analytics - getAvgUserQuestionsPerSessionPerDay", () => { + let app: INestApplication + let request: Requester + let setup: Awaited> + let repositories: AllRepositories + + let organizationId: string + let projectId: string + let accessToken: string | undefined = "token" + let auth0Id = "auth0|123" + + const day1Start = new Date("2026-01-01T00:00:00.000Z") + const day2Start = new Date("2026-01-02T00:00:00.000Z") + const day3Start = new Date("2026-01-03T00:00:00.000Z") + const day3End = new Date(day3Start.getTime() + 24 * 60 * 60 * 1000 - 1) + + const expectedDays = [ + { date: day1Start.toISOString().slice(0, 10), value: 1 }, + { date: day2Start.toISOString().slice(0, 10), value: 4 }, + { date: day3Start.toISOString().slice(0, 10), value: 0 }, + ] + + beforeAll(async () => { + setup = await setupTransactionalTestDatabase({ + additionalImports: [ProjectsAnalyticsModule], + applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id), + }) + repositories = setup.getAllRepositories() + app = setup.module.createNestApplication() + await app.init() + request = testRequester(app) + }) + + beforeEach(async () => { + await clearTestDatabase(setup.dataSource) + accessToken = "token" + auth0Id = "auth0|123" + }) + + afterAll(async () => { + await teardownTestDatabase(setup) + await app.close() + }) + + const createContext = async () => { + const { organization, project, user } = await createOrganizationWithProject(repositories, { + projectMembership: { role: "admin" }, + }) + organizationId = organization.id + projectId = project.id + auth0Id = user.auth0Id + + const agent = agentFactory.transient({ organization, project }).build() + await repositories.agentRepository.save(agent) + + const session1Day1 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day1Start.getTime() + 3600 * 1000), updatedAt: new Date() }) + const session2Day1 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day1Start.getTime() + 2 * 3600 * 1000), updatedAt: new Date() }) + const session3Day2 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day2Start.getTime() + 3600 * 1000), updatedAt: new Date() }) + + await repositories.conversationAgentSessionRepository.save([ + session1Day1, + session2Day1, + session3Day2, + ]) + + await repositories.agentMessageRepository.save([ + agentMessageFactory + .user() + .transient({ organization, project, session: session1Day1 }) + .build({ createdAt: new Date(day1Start.getTime() + 10 * 60 * 1000) }), + agentMessageFactory + .user() + .transient({ organization, project, session: session1Day1 }) + .build({ createdAt: new Date(day1Start.getTime() + 20 * 60 * 1000) }), + ...Array.from({ length: 4 }, (_value, messageIndex) => + agentMessageFactory + .user() + .transient({ organization, project, session: session3Day2 }) + .build({ + createdAt: new Date(day2Start.getTime() + (messageIndex + 1) * 5 * 60 * 1000), + }), + ), + ]) + } + + const subject = async () => + request({ + route: AnalyticsRoutes.getAvgUserQuestionsPerSessionPerDay, + pathParams: removeNullish({ organizationId, projectId }), + token: accessToken, + query: { + startAt: String(day1Start.getTime()), + endAt: String(day3End.getTime()), + }, + }) + + it("returns avg user questions per session per day including zeros", async () => { + await createContext() + const response = await subject() + expectResponse(response, 200) + expect(response.body.data).toEqual(expectedDays) + }) +}) diff --git a/apps/api/src/domains/analytics/projects-analytics/e2e-tests/get-conversations-per-day.spec.ts b/apps/api/src/domains/analytics/projects-analytics/e2e-tests/get-conversations-per-day.spec.ts new file mode 100644 index 0000000..7d09363 --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/e2e-tests/get-conversations-per-day.spec.ts @@ -0,0 +1,127 @@ +import { AnalyticsRoutes } from "@caseai-connect/api-contracts" +import type { INestApplication } from "@nestjs/common" +import type { App } from "supertest/types" +import { clearTestDatabase } from "@/common/test/test-database" +import { + type AllRepositories, + setupTransactionalTestDatabase, + teardownTestDatabase, +} from "@/common/test/test-transaction-manager" +import { removeNullish } from "@/common/utils/remove-nullish" +import { agentFactory } from "@/domains/agents/agent.factory" +import { conversationAgentSessionFactory } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.factory" +import { agentMessageFactory } from "@/domains/agents/shared/agent-session-messages/agent-messages.factory" +import { createOrganizationWithProject } from "@/domains/organizations/organization.factory" +import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers" +import { expectResponse, type Requester, testRequester } from "../../../../../test/request" +import { ProjectsAnalyticsModule } from "../projects-analytics.module" + +describe("Projects Analytics - getConversationsPerDay", () => { + let app: INestApplication + let request: Requester + let setup: Awaited> + let repositories: AllRepositories + + let organizationId: string + let projectId: string + let accessToken: string | undefined = "token" + let auth0Id = "auth0|123" + + const day1Start = new Date("2026-01-01T00:00:00.000Z") + const day2Start = new Date("2026-01-02T00:00:00.000Z") + const day3Start = new Date("2026-01-03T00:00:00.000Z") + const day3End = new Date(day3Start.getTime() + 24 * 60 * 60 * 1000 - 1) + + const expectedDays = [ + { date: day1Start.toISOString().slice(0, 10), value: 2 }, + { date: day2Start.toISOString().slice(0, 10), value: 1 }, + { date: day3Start.toISOString().slice(0, 10), value: 0 }, + ] + + beforeAll(async () => { + setup = await setupTransactionalTestDatabase({ + additionalImports: [ProjectsAnalyticsModule], + applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id), + }) + repositories = setup.getAllRepositories() + app = setup.module.createNestApplication() + await app.init() + request = testRequester(app) + }) + + beforeEach(async () => { + await clearTestDatabase(setup.dataSource) + accessToken = "token" + auth0Id = "auth0|123" + }) + + afterAll(async () => { + await teardownTestDatabase(setup) + await app.close() + }) + + const createContext = async () => { + const { organization, project, user } = await createOrganizationWithProject(repositories, { + projectMembership: { role: "admin" }, + }) + organizationId = organization.id + projectId = project.id + auth0Id = user.auth0Id + + const agent = agentFactory.transient({ organization, project }).build() + await repositories.agentRepository.save(agent) + + const session1Day1 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day1Start.getTime() + 3600 * 1000), updatedAt: new Date() }) + const session2Day1 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day1Start.getTime() + 2 * 3600 * 1000), updatedAt: new Date() }) + const session3Day2 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day2Start.getTime() + 3600 * 1000), updatedAt: new Date() }) + + await repositories.conversationAgentSessionRepository.save([ + session1Day1, + session2Day1, + session3Day2, + ]) + + await repositories.agentMessageRepository.save([ + agentMessageFactory + .user() + .transient({ organization, project, session: session1Day1 }) + .build({ createdAt: new Date(day1Start.getTime() + 10 * 60 * 1000) }), + agentMessageFactory + .user() + .transient({ organization, project, session: session1Day1 }) + .build({ createdAt: new Date(day1Start.getTime() + 20 * 60 * 1000) }), + ...Array.from({ length: 4 }, (_value, messageIndex) => + agentMessageFactory + .user() + .transient({ organization, project, session: session3Day2 }) + .build({ + createdAt: new Date(day2Start.getTime() + (messageIndex + 1) * 5 * 60 * 1000), + }), + ), + ]) + } + + const subject = async () => + request({ + route: AnalyticsRoutes.getConversationsPerDay, + pathParams: removeNullish({ organizationId, projectId }), + token: accessToken, + query: { + startAt: String(day1Start.getTime()), + endAt: String(day3End.getTime()), + }, + }) + + it("returns conversations per day including zeros", async () => { + await createContext() + const response = await subject() + expectResponse(response, 200) + expect(response.body.data).toEqual(expectedDays) + }) +}) diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.controller.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.controller.ts new file mode 100644 index 0000000..ade2b18 --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.controller.ts @@ -0,0 +1,55 @@ +import { AnalyticsRoutes as Routes } from "@caseai-connect/api-contracts" +import { Controller, Get, ParseIntPipe, Query, Req, UseGuards } from "@nestjs/common" +import type { EndpointRequestWithProject } from "@/common/context/request.interface" +import { getRequiredConnectScope } from "@/common/context/request-context.helpers" +import { RequireContext } from "@/common/context/require-context.decorator" +import { ResourceContextGuard } from "@/common/context/resource-context.guard" +import { CheckPolicy } from "@/common/policies/check-policy.decorator" +import { JwtAuthGuard } from "@/domains/auth/jwt-auth.guard" +import { UserGuard } from "@/domains/users/user.guard" +import { ProjectsAnalyticsGuard } from "./projects-analytics.guard" +import { toAnalyticsDailyPointDto } from "./projects-analytics.helpers" +// biome-ignore lint/style/useImportType: Required at runtime for NestJS DI +import { ProjectsAnalyticsService } from "./projects-analytics.service" +import type { AnalyticsDailyPoint } from "./projects-analytics.types" + +@UseGuards(JwtAuthGuard, UserGuard, ResourceContextGuard, ProjectsAnalyticsGuard) +@RequireContext("organization", "project") +@Controller() +export class ProjectsAnalyticsController { + constructor(private readonly projectsAnalyticsService: ProjectsAnalyticsService) {} + + @Get(Routes.getConversationsPerDay.path) + @CheckPolicy((policy) => policy.canList()) + async getConversationsPerDay( + @Req() request: EndpointRequestWithProject, + @Query("startAt", ParseIntPipe) startAt: number, + @Query("endAt", ParseIntPipe) endAt: number, + ): Promise { + const conversationsPerDay: AnalyticsDailyPoint[] = + await this.projectsAnalyticsService.getConversationsPerDay({ + connectScope: getRequiredConnectScope(request), + startAt, + endAt, + }) + + return { data: toAnalyticsDailyPointDto(conversationsPerDay) } + } + + @Get(Routes.getAvgUserQuestionsPerSessionPerDay.path) + @CheckPolicy((policy) => policy.canList()) + async getAvgUserQuestionsPerSessionPerDay( + @Req() request: EndpointRequestWithProject, + @Query("startAt", ParseIntPipe) startAt: number, + @Query("endAt", ParseIntPipe) endAt: number, + ): Promise { + const avgUserQuestionsPerSessionPerDay: AnalyticsDailyPoint[] = + await this.projectsAnalyticsService.getAvgUserQuestionsPerSessionPerDay({ + connectScope: getRequiredConnectScope(request), + startAt, + endAt, + }) + + return { data: toAnalyticsDailyPointDto(avgUserQuestionsPerSessionPerDay) } + } +} diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.guard.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.guard.ts new file mode 100644 index 0000000..1c9ae7e --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.guard.ts @@ -0,0 +1,34 @@ +import type { CanActivate, ExecutionContext } from "@nestjs/common" +import { ForbiddenException, Injectable } from "@nestjs/common" +// biome-ignore lint/style/useImportType: Required at runtime for NestJS DI +import { Reflector } from "@nestjs/core" +import type { EndpointRequestWithProject } from "@/common/context/request.interface" +import { AUTH_ERRORS } from "@/common/errors/auth-errors" +import { CHECK_POLICY_KEY, type PolicyHandler } from "@/common/policies/check-policy.decorator" +import { requestToProjectPolicyContext } from "@/domains/projects/helpers" +import { ProjectsAnalyticsPolicy } from "./projects-analytics.policy" + +@Injectable() +export class ProjectsAnalyticsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest() as EndpointRequestWithProject + + const policy = new ProjectsAnalyticsPolicy( + requestToProjectPolicyContext(request), + request.project, + ) + + const policyHandler = this.reflector.getAllAndOverride(CHECK_POLICY_KEY, [ + context.getHandler(), + context.getClass(), + ]) + + if (!policyHandler || !policyHandler(policy)) { + throw new ForbiddenException(AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + } + + return true + } +} diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.helpers.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.helpers.ts new file mode 100644 index 0000000..86d9576 --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.helpers.ts @@ -0,0 +1,10 @@ +import type { AnalyticsDailyPoint } from "./projects-analytics.types" + +export function toAnalyticsDailyPointDto( + points: AnalyticsDailyPoint[], +): Array<{ date: string; value: number }> { + return points.map((point) => ({ + date: point.date, + value: point.value, + })) +} diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.module.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.module.ts new file mode 100644 index 0000000..66e4626 --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.module.ts @@ -0,0 +1,43 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { OrganizationContextResolver } from "@/common/context/resolvers/organization-context.resolver" +import { ProjectContextResolver } from "@/common/context/resolvers/project-context.resolver" +import { ProjectMembershipContextResolver } from "@/common/context/resolvers/project-membership-context.resolver" +import { ResourceContextGuard } from "@/common/context/resource-context.guard" +import { ConversationAgentSession } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.entity" +import { AgentMessage } from "@/domains/agents/shared/agent-session-messages/agent-message.entity" +import { AuthModule } from "@/domains/auth/auth.module" +import { OrganizationMembership } from "@/domains/organizations/memberships/organization-membership.entity" +import { Organization } from "@/domains/organizations/organization.entity" +import { ProjectMembership } from "@/domains/projects/memberships/project-membership.entity" +import { Project } from "@/domains/projects/project.entity" +import { UsersModule } from "@/domains/users/users.module" +import { ProjectsAnalyticsController } from "./projects-analytics.controller" +import { ProjectsAnalyticsGuard } from "./projects-analytics.guard" +import { ProjectsAnalyticsService } from "./projects-analytics.service" + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ConversationAgentSession, + AgentMessage, + Project, + Organization, + OrganizationMembership, + ProjectMembership, + ]), + AuthModule, + UsersModule, + ], + providers: [ + ProjectsAnalyticsService, + ProjectsAnalyticsGuard, + ResourceContextGuard, + OrganizationContextResolver, + ProjectContextResolver, + ProjectMembershipContextResolver, + ], + controllers: [ProjectsAnalyticsController], + exports: [ProjectsAnalyticsService], +}) +export class ProjectsAnalyticsModule {} diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.policy.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.policy.ts new file mode 100644 index 0000000..1905fca --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.policy.ts @@ -0,0 +1,11 @@ +import { ProjectPolicy } from "@/domains/projects/project.policy" + +export class ProjectsAnalyticsPolicy extends ProjectPolicy { + /** + * Analytics are sensitive: only `admin` role can access. + * `owner` is explicitly not allowed. + */ + canList(): boolean { + return this.canAccessProject() && this.isProjectAdmin() + } +} diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.service.spec.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.service.spec.ts new file mode 100644 index 0000000..958adad --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.service.spec.ts @@ -0,0 +1,114 @@ +import { clearTestDatabase } from "@/common/test/test-database" +import { + type AllRepositories, + setupTransactionalTestDatabase, + teardownTestDatabase, +} from "@/common/test/test-transaction-manager" +import { agentFactory } from "@/domains/agents/agent.factory" +import { conversationAgentSessionFactory } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.factory" +import { agentMessageFactory } from "@/domains/agents/shared/agent-session-messages/agent-messages.factory" +import { createOrganizationWithProject } from "@/domains/organizations/organization.factory" +import { ProjectsAnalyticsModule } from "./projects-analytics.module" +import { ProjectsAnalyticsService } from "./projects-analytics.service" + +describe("ProjectsAnalyticsService", () => { + let setup: Awaited> + let repositories: AllRepositories + let service: ProjectsAnalyticsService + + beforeAll(async () => { + setup = await setupTransactionalTestDatabase({ + additionalImports: [ProjectsAnalyticsModule], + }) + repositories = setup.getAllRepositories() + await clearTestDatabase(setup.dataSource) + service = setup.module.get(ProjectsAnalyticsService) + }) + + afterAll(async () => { + await teardownTestDatabase(setup) + }) + + beforeEach(async () => { + await clearTestDatabase(setup.dataSource) + }) + + it("returns conversations per day and avg user questions per session per day", async () => { + const day1Start = new Date("2026-01-01T00:00:00.000Z") + const day2Start = new Date("2026-01-02T00:00:00.000Z") + const day3Start = new Date("2026-01-03T00:00:00.000Z") + const day3End = new Date(day3Start.getTime() + 24 * 60 * 60 * 1000 - 1) + + const { organization, project, user } = await createOrganizationWithProject(repositories) + + const agent = agentFactory.transient({ organization, project }).build() + await repositories.agentRepository.save(agent) + + const session1Day1 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day1Start.getTime() + 3600 * 1000), updatedAt: new Date() }) + const session2Day1 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day1Start.getTime() + 2 * 3600 * 1000), updatedAt: new Date() }) + const session3Day2 = conversationAgentSessionFactory + .transient({ organization, project, agent, user }) + .build({ createdAt: new Date(day2Start.getTime() + 3600 * 1000), updatedAt: new Date() }) + + await repositories.conversationAgentSessionRepository.save([ + session1Day1, + session2Day1, + session3Day2, + ]) + + const userMessagesSession1 = [ + agentMessageFactory + .user() + .transient({ organization, project, session: session1Day1 }) + .build({ createdAt: new Date(day1Start.getTime() + 10 * 60 * 1000) }), + agentMessageFactory + .user() + .transient({ organization, project, session: session1Day1 }) + .build({ createdAt: new Date(day1Start.getTime() + 20 * 60 * 1000) }), + ] + + const userMessagesSession3 = Array.from({ length: 4 }, (_unusedValue, messageIndex) => + agentMessageFactory + .user() + .transient({ organization, project, session: session3Day2 }) + .build({ + createdAt: new Date(day2Start.getTime() + (messageIndex + 1) * 5 * 60 * 1000), + }), + ) + + await repositories.agentMessageRepository.save([ + ...userMessagesSession1, + ...userMessagesSession3, + ]) + + const connectScope = { organizationId: organization.id, projectId: project.id, userId: user.id } + + const conversations = await service.getConversationsPerDay({ + connectScope, + startAt: day1Start.getTime(), + endAt: day3End.getTime(), + }) + + expect(conversations).toEqual([ + { date: day1Start.toISOString().slice(0, 10), value: 2 }, + { date: day2Start.toISOString().slice(0, 10), value: 1 }, + { date: day3Start.toISOString().slice(0, 10), value: 0 }, + ]) + + const averages = await service.getAvgUserQuestionsPerSessionPerDay({ + connectScope, + startAt: day1Start.getTime(), + endAt: day3End.getTime(), + }) + + expect(averages).toEqual([ + { date: day1Start.toISOString().slice(0, 10), value: 1 }, + { date: day2Start.toISOString().slice(0, 10), value: 4 }, + { date: day3Start.toISOString().slice(0, 10), value: 0 }, + ]) + }) +}) diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.service.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.service.ts new file mode 100644 index 0000000..33ca28e --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.service.ts @@ -0,0 +1,156 @@ +import type { TimeType } from "@caseai-connect/api-contracts" +import { BadRequestException, Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import type { Repository } from "typeorm" +import { ConnectRepository } from "@/common/entities/connect-repository" +import type { RequiredConnectScope } from "@/common/entities/connect-required-fields" +import { ConversationAgentSession } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.entity" +import { AgentMessage } from "@/domains/agents/shared/agent-session-messages/agent-message.entity" + +import type { AnalyticsDailyPoint } from "./projects-analytics.types" + +@Injectable() +export class ProjectsAnalyticsService { + private readonly conversationAgentSessionConnectRepository: ConnectRepository + private readonly conversationAgentSessionAlias = "conversationAgentSession" + private readonly agentMessageAlias = "agentMessage" + + constructor( + @InjectRepository(ConversationAgentSession) + conversationAgentSessionRepository: Repository, + ) { + this.conversationAgentSessionConnectRepository = new ConnectRepository( + conversationAgentSessionRepository, + this.conversationAgentSessionAlias, + ) + } + + async getConversationsPerDay({ + connectScope, + startAt, + endAt, + }: { + connectScope: RequiredConnectScope + startAt: TimeType + endAt: TimeType + }): Promise { + const dayKeys = this.getUtcDayKeys(startAt, endAt) + const dayExpr = this.getDayKeySql(this.conversationAgentSessionAlias, "created_at") + const createdAtCol = this.getQualifiedColumnSql( + this.conversationAgentSessionAlias, + "created_at", + ) + + const raw = await this.conversationAgentSessionConnectRepository + .newQueryBuilderWithConnectScope(connectScope) + .select(dayExpr, "date") + .addSelect("COUNT(*)::int", "value") + .where(`${createdAtCol} BETWEEN :startAt AND :endAt`, { + startAt: new Date(startAt), + endAt: new Date(endAt), + }) + .groupBy(dayExpr) + .orderBy("date", "ASC") + .getRawMany<{ + date: string + value: string + }>() + + const valueByDay = new Map(raw.map((row) => [row.date, Number(row.value)])) + + return dayKeys.map((day) => ({ + date: day, + value: valueByDay.get(day) ?? 0, + })) + } + + async getAvgUserQuestionsPerSessionPerDay({ + connectScope, + startAt, + endAt, + }: { + connectScope: RequiredConnectScope + startAt: TimeType + endAt: TimeType + }): Promise { + const dayKeys = this.getUtcDayKeys(startAt, endAt) + + const dayExpr = this.getDayKeySql(this.conversationAgentSessionAlias, "created_at") + const createdAtCol = this.getQualifiedColumnSql( + this.conversationAgentSessionAlias, + "created_at", + ) + const conversationIdCol = this.getQualifiedColumnSql(this.conversationAgentSessionAlias, "id") + const agentMessageIdCol = this.getQualifiedColumnSql(this.agentMessageAlias, "id") + + const raw = await this.conversationAgentSessionConnectRepository + .newQueryBuilderWithConnectScope(connectScope) + .leftJoin( + AgentMessage, + this.agentMessageAlias, + `${this.getQualifiedColumnSql(this.agentMessageAlias, "session_id")} = ${this.getQualifiedColumnSql(this.conversationAgentSessionAlias, "id")} + AND ${this.getQualifiedColumnSql(this.agentMessageAlias, "role")} = :userRole`, + { userRole: "user" }, + ) + .select(dayExpr, "date") + .addSelect( + `COALESCE((COUNT(${agentMessageIdCol})::float / NULLIF(COUNT(DISTINCT ${conversationIdCol}), 0)), 0)`, + "value", + ) + .where(`${createdAtCol} BETWEEN :startAt AND :endAt`, { + startAt: new Date(startAt), + endAt: new Date(endAt), + }) + .groupBy(dayExpr) + .orderBy("date", "ASC") + .getRawMany<{ + date: string + value: string + }>() + + const valueByDay = new Map(raw.map((row) => [row.date, Number(row.value)])) + + return dayKeys.map((day) => ({ + date: day, + value: valueByDay.get(day) ?? 0, + })) + } + + private getUtcDayKeys(startAt: TimeType, endAt: TimeType): string[] { + if (endAt < startAt) { + throw new BadRequestException("Invalid date range") + } + + const startDate = new Date(startAt) + const endDate = new Date(endAt) + + const startUtcDay = new Date( + Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate()), + ) + const endUtcDay = new Date( + Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate()), + ) + + const days: string[] = [] + for ( + let current = startUtcDay; + current.getTime() <= endUtcDay.getTime(); + current = new Date(current.getTime() + 24 * 60 * 60 * 1000) + ) { + days.push(current.toISOString().slice(0, 10)) + } + + return days + } + + private getQualifiedColumnSql(alias: string, columnName: string): string { + // Quote the alias to preserve case. Postgres folds unquoted identifiers to lowercase. + return `"${alias}"."${columnName}"` + } + + private getDayKeySql(alias: string, createdAtColumnName: string): string { + // Match UTC day bucketing used by previous `toISOString().slice(0, 10)`. + const createdAtCol = this.getQualifiedColumnSql(alias, createdAtColumnName) + return `to_char(timezone('UTC', ${createdAtCol})::date, 'YYYY-MM-DD')` + } +} diff --git a/apps/api/src/domains/analytics/projects-analytics/projects-analytics.types.ts b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.types.ts new file mode 100644 index 0000000..2cda4fc --- /dev/null +++ b/apps/api/src/domains/analytics/projects-analytics/projects-analytics.types.ts @@ -0,0 +1 @@ +export type AnalyticsDailyPoint = { date: string; value: number } diff --git a/apps/api/test/request.ts b/apps/api/test/request.ts index a9238df..97ef550 100644 --- a/apps/api/test/request.ts +++ b/apps/api/test/request.ts @@ -10,6 +10,8 @@ export const testRequester = route: T pathParams?: Record token?: string + /** Query string for GET (and other methods that support it). Values should be strings. */ + query?: Record request?: T["request"] }): Promise & { body: T["response"] }> => { const { token, route } = params @@ -19,6 +21,10 @@ export const testRequester = const req = request(app.getHttpServer())[method](path) + if (params.query) { + req.query(params.query) + } + if ( "request" in params && params.request && diff --git a/apps/web/package.json b/apps/web/package.json index 0f60abc..411e824 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.11.0", "react-speech-recognition": "^4.0.1", + "recharts": "^3.8.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", @@ -45,7 +46,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@storybook/react-vite": "^10.1.10", + "@storybook/react-vite": "^10.3.3", "@types/lodash": "^4.17.23", "@types/node": "^24.10.1", "@types/react": "^19.2.5", @@ -56,7 +57,7 @@ "babel-plugin-react-compiler": "^1.0.0", "baseline-browser-mapping": "^2.9.11", "globals": "^16.5.0", - "storybook": "^10.1.10", + "storybook": "^10.3.3", "storybook-addon-remix-react-router": "^6.0.0", "typescript": "~5.9.3", "vite": "^7.2.4", diff --git a/apps/web/src/components/layouts/sidebar/SidebarBreadcrumb.tsx b/apps/web/src/components/layouts/sidebar/SidebarBreadcrumb.tsx index 5b75b0e..f6a34c0 100644 --- a/apps/web/src/components/layouts/sidebar/SidebarBreadcrumb.tsx +++ b/apps/web/src/components/layouts/sidebar/SidebarBreadcrumb.tsx @@ -2,6 +2,7 @@ import { Breadcrumb, BreadcrumbList } from "@caseai-connect/ui/shad/breadcrumb" import type { Organization } from "@/features/organizations/organizations.models" import { BreadcrumbAgent } from "./breadcrumb/BreadcrumbAgent" import { BreadcrumbAgentSession } from "./breadcrumb/BreadcrumbAgentSession" +import { BreadcrumbAnalytics } from "./breadcrumb/BreadcrumbAnalytics" import { BreadcrumbDocuments } from "./breadcrumb/BreadcrumbDocuments" import { BreadcrumbEvaluations } from "./breadcrumb/BreadcrumbEvaluations" import { BreadcrumbFeedback } from "./breadcrumb/BreadcrumbFeedback" @@ -22,6 +23,8 @@ export function SidebarBreadcrumb({ organization }: { organization: Organization + + diff --git a/apps/web/src/components/layouts/sidebar/breadcrumb/BreadcrumbAnalytics.tsx b/apps/web/src/components/layouts/sidebar/breadcrumb/BreadcrumbAnalytics.tsx new file mode 100644 index 0000000..26b3f20 --- /dev/null +++ b/apps/web/src/components/layouts/sidebar/breadcrumb/BreadcrumbAnalytics.tsx @@ -0,0 +1,22 @@ +import { BreadcrumbItem, BreadcrumbSeparator } from "@caseai-connect/ui/shad/breadcrumb" +import { DotIcon } from "lucide-react" +import { useTranslation } from "react-i18next" +import { useFeatureFlags } from "@/hooks/use-feature-flags" +import { useIsRoute } from "@/hooks/use-is-route" +import { RouteNames } from "@/routes/helpers" + +export function BreadcrumbAnalytics() { + const { hasFeature } = useFeatureFlags() + const { isRoute } = useIsRoute() + const isAnalyticsRoute = isRoute(RouteNames.ANALYTICS) + const { t } = useTranslation("analytics") + if (!hasFeature("project-analytics") || !isAnalyticsRoute) return null + return ( + <> + + + + {t("title")} + + ) +} diff --git a/apps/web/src/components/sidebar/nav/NavAnalytics.tsx b/apps/web/src/components/sidebar/nav/NavAnalytics.tsx new file mode 100644 index 0000000..7108965 --- /dev/null +++ b/apps/web/src/components/sidebar/nav/NavAnalytics.tsx @@ -0,0 +1,41 @@ +import { SidebarMenuButton, SidebarMenuItem } from "@caseai-connect/ui/shad/sidebar" +import { BarChart3Icon } from "lucide-react" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { Link, useLocation } from "react-router-dom" +import { useAbility } from "@/hooks/use-ability" +import { buildAnalyticsPath } from "@/routes/helpers" + +export function NavAnalytics({ + organizationId, + projectId, +}: { + organizationId: string + projectId: string +}) { + const { t } = useTranslation("analytics") + const { isAdminInterface } = useAbility() + const isActive = useIsAnalyticsActive(projectId) + + if (!isAdminInterface) return null + + const path = buildAnalyticsPath({ organizationId, projectId }) + return ( + + + + + {t("title")} + + + + ) +} + +function useIsAnalyticsActive(projectId: string) { + const location = useLocation() + return useMemo( + () => location.pathname.endsWith(`/p/${projectId}/analytics`), + [location.pathname, projectId], + ) +} diff --git a/apps/web/src/di/services.ts b/apps/web/src/di/services.ts index 20c578e..ccefa64 100644 --- a/apps/web/src/di/services.ts +++ b/apps/web/src/di/services.ts @@ -6,6 +6,7 @@ import type { IConversationAgentSessionsSpi } from "@/features/agents/conversati import type { IExtractionAgentSessionsSpi } from "@/features/agents/extraction-agent-sessions/extraction-agent-sessions.spi" import type { IFormAgentSessionsSpi } from "@/features/agents/form-agent-sessions/form-agent-sessions.spi" import type { IAgentSessionMessagesSpi } from "@/features/agents/shared/agent-session-messages/agent-session-messages.spi" +import type { IAnalyticsSpi } from "@/features/analytics/analytics.spi" import type { IDocumentTagsSpi } from "@/features/document-tags/document-tags.spi" import type { IDocumentsSpi } from "@/features/documents/documents.spi" import type { IEvaluationReportsSpi } from "@/features/evaluation-reports/evaluation-reports.spi" @@ -17,6 +18,7 @@ import type { IProjectMembershipsSpi } from "@/features/project-memberships/proj import type { IProjectsSpi } from "@/features/projects/projects.spi" export type Services = { + analytics: IAnalyticsSpi agentMemberships: IAgentMembershipsSpi agentMessageFeedback: IAgentMessageFeedbackSpi agents: IAgentsSpi diff --git a/apps/web/src/external/axios.services.ts b/apps/web/src/external/axios.services.ts index 8720edf..14a7ea0 100644 --- a/apps/web/src/external/axios.services.ts +++ b/apps/web/src/external/axios.services.ts @@ -5,6 +5,7 @@ import agentsApi from "@/features/agents/external/agents.api" import extractionAgentSessionsApi from "@/features/agents/extraction-agent-sessions/external/extraction-agent-sessions.api" import formAgentSessionsApi from "@/features/agents/form-agent-sessions/external/form-agent-sessions.api" import agentSessionMessagesApi from "@/features/agents/shared/agent-session-messages/external/agent-session-messages.api" +import analyticsApi from "@/features/analytics/external/analytics.api" import documentTagsApi from "@/features/document-tags/external/document-tags.api" import documentsApi from "@/features/documents/external/documents.api" import evaluationReportsApi from "@/features/evaluation-reports/external/evaluation-reports.api" @@ -16,6 +17,7 @@ import projectMembershipsApi from "@/features/project-memberships/external/proje import projectsApi from "@/features/projects/external/projects.api" export const services = { + analytics: analyticsApi, agentMemberships: agentMembershipsApi, agentMessageFeedback: agentMessageFeedbackApi, agents: agentsApi, diff --git a/apps/web/src/features/analytics/analytics-date-range.ts b/apps/web/src/features/analytics/analytics-date-range.ts new file mode 100644 index 0000000..15febcd --- /dev/null +++ b/apps/web/src/features/analytics/analytics-date-range.ts @@ -0,0 +1,18 @@ +import type { DateRange } from "react-day-picker" + +/** + * Converts an inclusive local calendar range to API `startAt` / `endAt` (ms), + * matching local midnight → end-of-day for the selected dates. + */ +export function dateRangeToAnalyticsQueryBounds( + range: DateRange | undefined, +): { startAt: number; endAt: number } | null { + if (!range?.from || !range.to) { + return null + } + const from = range.from + const to = range.to + const startAt = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime() + const endAt = new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999).getTime() + return { startAt, endAt } +} diff --git a/apps/web/src/features/analytics/analytics.middleware.ts b/apps/web/src/features/analytics/analytics.middleware.ts new file mode 100644 index 0000000..52b28c5 --- /dev/null +++ b/apps/web/src/features/analytics/analytics.middleware.ts @@ -0,0 +1,21 @@ +import { createListenerMiddleware } from "@reduxjs/toolkit" +import type { AppDispatch, RootState } from "@/store/types" +import { hasInterfaceChanged } from "../auth/auth.selectors" +import { hasProjectChanged } from "../projects/projects.selectors" +import { analyticsActions } from "./analytics.slice" + +const listenerMiddleware = createListenerMiddleware() + +listenerMiddleware.startListening({ + predicate(_, currentState, originalState) { + return ( + hasInterfaceChanged(originalState, currentState) || + hasProjectChanged(originalState, currentState) + ) + }, + effect: (_, listenerApi) => { + listenerApi.dispatch(analyticsActions.reset()) + }, +}) + +export { listenerMiddleware as analyticsMiddleware } diff --git a/apps/web/src/features/analytics/analytics.models.ts b/apps/web/src/features/analytics/analytics.models.ts new file mode 100644 index 0000000..9680c7f --- /dev/null +++ b/apps/web/src/features/analytics/analytics.models.ts @@ -0,0 +1,3 @@ +import type { AnalyticsDailyPointDto } from "@caseai-connect/api-contracts" + +export type AnalyticsDailyPoint = AnalyticsDailyPointDto diff --git a/apps/web/src/features/analytics/analytics.selectors.ts b/apps/web/src/features/analytics/analytics.selectors.ts new file mode 100644 index 0000000..5f99949 --- /dev/null +++ b/apps/web/src/features/analytics/analytics.selectors.ts @@ -0,0 +1,11 @@ +import type { RootState } from "@/store" +import type { AsyncData } from "@/store/async-data-status" +import type { AnalyticsDailyPoint } from "./analytics.models" + +export const selectAnalyticsConversationsPerDay = ( + state: RootState, +): AsyncData => state.analytics.conversationsPerDay + +export const selectAnalyticsAvgUserQuestionsPerSessionPerDay = ( + state: RootState, +): AsyncData => state.analytics.avgUserQuestionsPerSessionPerDay diff --git a/apps/web/src/features/analytics/analytics.slice.ts b/apps/web/src/features/analytics/analytics.slice.ts new file mode 100644 index 0000000..b9be1d1 --- /dev/null +++ b/apps/web/src/features/analytics/analytics.slice.ts @@ -0,0 +1,71 @@ +import { createSlice } from "@reduxjs/toolkit" +import { ADS, type AsyncData } from "@/store/async-data-status" +import type { AnalyticsDailyPoint } from "./analytics.models" +import { loadProjectAnalytics } from "./analytics.thunks" + +interface State { + conversationsPerDay: AsyncData + avgUserQuestionsPerSessionPerDay: AsyncData +} + +const emptySeries: AsyncData = { + status: ADS.Uninitialized, + error: null, + value: null, +} + +const initialState: State = { + conversationsPerDay: { ...emptySeries }, + avgUserQuestionsPerSessionPerDay: { ...emptySeries }, +} + +const slice = createSlice({ + name: "analytics", + initialState, + reducers: { + reset: () => initialState, + }, + extraReducers: (builder) => { + builder + .addCase(loadProjectAnalytics.pending, (state) => { + if (!ADS.isFulfilled(state.conversationsPerDay)) { + state.conversationsPerDay.status = ADS.Loading + } + if (!ADS.isFulfilled(state.avgUserQuestionsPerSessionPerDay)) { + state.avgUserQuestionsPerSessionPerDay.status = ADS.Loading + } + state.conversationsPerDay.error = null + state.avgUserQuestionsPerSessionPerDay.error = null + }) + .addCase(loadProjectAnalytics.fulfilled, (state, action) => { + state.conversationsPerDay = { + status: ADS.Fulfilled, + error: null, + value: action.payload.conversationsPerDay, + } + state.avgUserQuestionsPerSessionPerDay = { + status: ADS.Fulfilled, + error: null, + value: action.payload.avgUserQuestionsPerSessionPerDay, + } + }) + .addCase(loadProjectAnalytics.rejected, (state, action) => { + const message = action.error.message || "Failed to load analytics" + state.conversationsPerDay = { + status: ADS.Error, + error: message, + value: null, + } + state.avgUserQuestionsPerSessionPerDay = { + status: ADS.Error, + error: message, + value: null, + } + }) + }, +}) + +export type { State as AnalyticsState } +export const analyticsInitialState = initialState +export const analyticsActions = { ...slice.actions } +export const analyticsSliceReducer = slice.reducer diff --git a/apps/web/src/features/analytics/analytics.spi.ts b/apps/web/src/features/analytics/analytics.spi.ts new file mode 100644 index 0000000..69d1cf0 --- /dev/null +++ b/apps/web/src/features/analytics/analytics.spi.ts @@ -0,0 +1,16 @@ +import type { AnalyticsDailyPoint } from "./analytics.models" + +export interface IAnalyticsSpi { + getConversationsPerDay(params: { + organizationId: string + projectId: string + startAt: number + endAt: number + }): Promise + getAvgUserQuestionsPerSessionPerDay(params: { + organizationId: string + projectId: string + startAt: number + endAt: number + }): Promise +} diff --git a/apps/web/src/features/analytics/analytics.thunks.ts b/apps/web/src/features/analytics/analytics.thunks.ts new file mode 100644 index 0000000..880491d --- /dev/null +++ b/apps/web/src/features/analytics/analytics.thunks.ts @@ -0,0 +1,33 @@ +import { createAsyncThunk } from "@reduxjs/toolkit" +import { hasFeatureOrThrow } from "@/hooks/use-feature-flags" +import type { RootState, ThunkExtraArg } from "@/store" +import { getCurrentIds } from "../helpers" +import type { AnalyticsDailyPoint } from "./analytics.models" + +type ThunkConfig = { state: RootState; extra: ThunkExtraArg } + +export const loadProjectAnalytics = createAsyncThunk< + { + conversationsPerDay: AnalyticsDailyPoint[] + avgUserQuestionsPerSessionPerDay: AnalyticsDailyPoint[] + }, + { startAt: number; endAt: number }, + ThunkConfig +>("analytics/loadProject", async ({ startAt, endAt }, { extra: { services }, getState }) => { + const state = getState() + hasFeatureOrThrow({ state, feature: "project-analytics" }) + const { organizationId, projectId } = getCurrentIds({ + state, + wantedIds: ["organizationId", "projectId"], + }) + const [conversationsPerDay, avgUserQuestionsPerSessionPerDay] = await Promise.all([ + services.analytics.getConversationsPerDay({ organizationId, projectId, startAt, endAt }), + services.analytics.getAvgUserQuestionsPerSessionPerDay({ + organizationId, + projectId, + startAt, + endAt, + }), + ]) + return { conversationsPerDay, avgUserQuestionsPerSessionPerDay } +}) diff --git a/apps/web/src/features/analytics/external/analytics.api.ts b/apps/web/src/features/analytics/external/analytics.api.ts new file mode 100644 index 0000000..7307575 --- /dev/null +++ b/apps/web/src/features/analytics/external/analytics.api.ts @@ -0,0 +1,36 @@ +import { type AnalyticsDailyPointDto, AnalyticsRoutes } from "@caseai-connect/api-contracts" +import { getAxiosInstance } from "@/external/axios" +import type { AnalyticsDailyPoint } from "../analytics.models" +import type { IAnalyticsSpi } from "../analytics.spi" + +export default { + getConversationsPerDay: async ({ organizationId, projectId, startAt, endAt }) => { + const axios = getAxiosInstance() + const response = await axios.get( + AnalyticsRoutes.getConversationsPerDay.getPath({ organizationId, projectId }), + dateRangeQueryParams(startAt, endAt), + ) + return toAnalyticsDailyPoints(response.data.data) + }, + getAvgUserQuestionsPerSessionPerDay: async ({ organizationId, projectId, startAt, endAt }) => { + const axios = getAxiosInstance() + const response = await axios.get< + typeof AnalyticsRoutes.getAvgUserQuestionsPerSessionPerDay.response + >( + AnalyticsRoutes.getAvgUserQuestionsPerSessionPerDay.getPath({ organizationId, projectId }), + dateRangeQueryParams(startAt, endAt), + ) + return toAnalyticsDailyPoints(response.data.data) + }, +} satisfies IAnalyticsSpi + +function dateRangeQueryParams(startAt: number, endAt: number) { + return { params: { startAt, endAt } } +} + +function toAnalyticsDailyPoints(dtos: AnalyticsDailyPointDto[]): AnalyticsDailyPoint[] { + return dtos.map((dto) => ({ + date: dto.date, + value: dto.value, + })) +} diff --git a/apps/web/src/features/analytics/locales/analytics.en.json b/apps/web/src/features/analytics/locales/analytics.en.json new file mode 100644 index 0000000..d0c5d2c --- /dev/null +++ b/apps/web/src/features/analytics/locales/analytics.en.json @@ -0,0 +1,16 @@ +{ + "analytics": { + "title": "Analytics", + "dateRangePlaceholder": "Date range", + "conversationsChart": { + "title": "Conversations per day", + "description": "Number of conversational sessions started each day", + "metricLabel": "Total conversations" + }, + "avgQuestionsChart": { + "title": "Avg. user questions per session", + "description": "Average user messages per session per day", + "metricLabel": "Per day" + } + } +} diff --git a/apps/web/src/features/analytics/locales/analytics.fr.json b/apps/web/src/features/analytics/locales/analytics.fr.json new file mode 100644 index 0000000..43ae2a0 --- /dev/null +++ b/apps/web/src/features/analytics/locales/analytics.fr.json @@ -0,0 +1,16 @@ +{ + "analytics": { + "title": "Analytique", + "dateRangePlaceholder": "Plage de dates", + "conversationsChart": { + "title": "Conversations par jour", + "description": "Nombre de sessions conversationnelles démarrées chaque jour", + "metricLabel": "Total des conversations" + }, + "avgQuestionsChart": { + "title": "Questions utilisateur moy. par session", + "description": "Moyenne des messages utilisateur par session et par jour", + "metricLabel": "Par jour" + } + } +} diff --git a/apps/web/src/routes/DashboardRoute.tsx b/apps/web/src/routes/DashboardRoute.tsx index a132134..eada657 100644 --- a/apps/web/src/routes/DashboardRoute.tsx +++ b/apps/web/src/routes/DashboardRoute.tsx @@ -10,6 +10,7 @@ import { SidebarLayout } from "@/components/layouts/SidebarLayout" import { ProjectList } from "@/components/ProjectList" import { RestrictedFeature } from "@/components/RestrictedFeature" import { SidebarAgentList } from "@/components/sidebar/list/SidebarAgentList" +import { NavAnalytics } from "@/components/sidebar/nav/NavAnalytics" import { NavDocuments } from "@/components/sidebar/nav/NavDocuments" import { NavEvaluation } from "@/components/sidebar/nav/NavEvaluation" import { NavProjectMemberships } from "@/components/sidebar/nav/NavProjectMemberships" @@ -99,6 +100,10 @@ function SidebarFooterChildren({ project }: { project: Project }) { + + + + diff --git a/apps/web/src/routes/Router.tsx b/apps/web/src/routes/Router.tsx index f1d3425..fa9e146 100644 --- a/apps/web/src/routes/Router.tsx +++ b/apps/web/src/routes/Router.tsx @@ -13,6 +13,7 @@ import { buildAppPath, buildStudioPath, RouteNames } from "./helpers" import { OnboardingRoute } from "./OnboardingRoute" import { ProtectedRoute } from "./ProtectedRoute" import { AgentMembershipsRoute } from "./studio/AgentMembershipsRoute" +import { AnalyticsRoute } from "./studio/AnalyticsRoute" import { DocumentsRoute } from "./studio/DocumentsRoute" import { EvaluationRoute } from "./studio/EvaluationRoute" import { FeedbackRoute } from "./studio/FeedbackRoute" @@ -69,6 +70,14 @@ const router = () => path: buildStudioPath(RouteNames.DOCUMENTS), element: , }, + { + path: buildStudioPath(RouteNames.ANALYTICS), + element: ( + + + + ), + }, { path: buildStudioPath(RouteNames.PROJECT_MEMBERSHIPS), element: , diff --git a/apps/web/src/routes/helpers.ts b/apps/web/src/routes/helpers.ts index 194d58b..0eae5a2 100644 --- a/apps/web/src/routes/helpers.ts +++ b/apps/web/src/routes/helpers.ts @@ -14,6 +14,7 @@ export enum RouteNames { STUDIO = "/studio", DOCUMENTS = "/o/:organizationId/p/:projectId/d", DOCUMENT = "/o/:organizationId/p/:projectId/d/:documentId", + ANALYTICS = "/o/:organizationId/p/:projectId/analytics", EVALUATION = "/o/:organizationId/p/:projectId/eval", PROJECT_MEMBERSHIPS = "/o/:organizationId/p/:projectId/members", FEEDBACK = "/o/:organizationId/p/:projectId/a/:agentId/f", @@ -51,6 +52,20 @@ export const buildDocumentsPath = ({ ) } +export const buildAnalyticsPath = ({ + organizationId, + projectId, +}: { + organizationId: string + projectId: string +}) => { + return buildStudioPath( + RouteNames.ANALYTICS.toString() + .replace(":organizationId", organizationId) + .replace(":projectId", projectId), + ) +} + export const buildEvaluationPath = ({ organizationId, projectId, diff --git a/apps/web/src/routes/studio/AnalyticsRoute.tsx b/apps/web/src/routes/studio/AnalyticsRoute.tsx new file mode 100644 index 0000000..bf322c6 --- /dev/null +++ b/apps/web/src/routes/studio/AnalyticsRoute.tsx @@ -0,0 +1,109 @@ +import ChartCard, { type DailyMetricPoint } from "@caseai-connect/ui/components/ChartCard" +import { DateRangeCalendarWithPresetsPopover } from "@caseai-connect/ui/components/DateRangeCalendarWithPresets" +import { getLast7DaysRange } from "@caseai-connect/ui/lib/date-range-presets" +import { useCallback, useEffect, useState } from "react" +import type { DateRange } from "react-day-picker" +import { useTranslation } from "react-i18next" +import { + selectAnalyticsAvgUserQuestionsPerSessionPerDay, + selectAnalyticsConversationsPerDay, +} from "@/features/analytics/analytics.selectors" +import { loadProjectAnalytics } from "@/features/analytics/analytics.thunks" +import { dateRangeToAnalyticsQueryBounds } from "@/features/analytics/analytics-date-range" +import { useAppDispatch, useAppSelector } from "@/store/hooks" +import { AsyncRoute } from "../AsyncRoute" + +function sumDailyMetricValues(series: DailyMetricPoint[]): number { + return series.reduce((sum, point) => sum + point.value, 0) +} + +function meanDailyMetricValues(series: DailyMetricPoint[]): number { + if (series.length === 0) { + return 0 + } + return sumDailyMetricValues(series) / series.length +} + +function getInitialAnalyticsBounds() { + const initialRange = getLast7DaysRange() + return dateRangeToAnalyticsQueryBounds({ + from: initialRange.from, + to: initialRange.to, + })! +} + +export function AnalyticsRoute() { + const dispatch = useAppDispatch() + const [bounds, setBounds] = useState(getInitialAnalyticsBounds) + + useEffect(() => { + void dispatch(loadProjectAnalytics(bounds)) + }, [dispatch, bounds]) + + const conversations = useAppSelector(selectAnalyticsConversationsPerDay) + const avgQuestions = useAppSelector(selectAnalyticsAvgUserQuestionsPerSessionPerDay) + + return ( + + {([conversationsPoints, avgQuestionsPoints]) => ( + + )} + + ) +} + +function WithData({ + conversationsPoints, + avgQuestionsPoints, + onAnalyticsRangeChange, +}: { + conversationsPoints: { date: string; value: number }[] + avgQuestionsPoints: { date: string; value: number }[] + onAnalyticsRangeChange: (nextBounds: { startAt: number; endAt: number }) => void +}) { + const { t } = useTranslation("analytics") + + const onRangeChange = useCallback( + (range: DateRange | undefined) => { + const next = dateRangeToAnalyticsQueryBounds(range) + if (next) { + onAnalyticsRangeChange(next) + } + }, + [onAnalyticsRangeChange], + ) + + return ( +
+
+ +
+ +
+ + +
+
+ ) +} diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index ba61dcf..f0ecb15 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -14,6 +14,8 @@ import { formAgentSessionsSliceReducer } from "@/features/agents/form-agent-sess import { agentSessionMessagesMiddleware } from "@/features/agents/shared/agent-session-messages/agent-session-messages.middleware" import { agentSessionMessagesSliceReducer } from "@/features/agents/shared/agent-session-messages/agent-session-messages.slice" import { baseAgentSessionsMiddleware } from "@/features/agents/shared/base-agent-session/base-agent-sessions.middleware" +import { analyticsMiddleware } from "@/features/analytics/analytics.middleware" +import { analyticsSliceReducer } from "@/features/analytics/analytics.slice" import { authMiddleware } from "@/features/auth/auth.middleware" import { authSliceReducer } from "@/features/auth/auth.slice" import { documentTagsMiddleware } from "@/features/document-tags/document-tags.middleware" @@ -37,6 +39,7 @@ import type { ThunkExtraArg } from "./types" export const store = configureStore({ reducer: { + analytics: analyticsSliceReducer, agentMemberships: agentMembershipsSliceReducer, agentMessageFeedback: agentMessageFeedbackSliceReducer, agents: agentsSliceReducer, @@ -62,6 +65,7 @@ export const store = configureStore({ extraArgument: { services: getServices() } satisfies ThunkExtraArg, }, }).prepend( + analyticsMiddleware.middleware, agentMembershipsMiddleware.middleware, agentMessageFeedbackMiddleware.middleware, agentSessionMessagesMiddleware.middleware, diff --git a/apps/web/src/store/types.ts b/apps/web/src/store/types.ts index 6d1b763..9089e47 100644 --- a/apps/web/src/store/types.ts +++ b/apps/web/src/store/types.ts @@ -8,6 +8,7 @@ import type { currentAgentSessionIdSliceReducer } from "@/features/agents/curren import type { extractionAgentSessionsSliceReducer } from "@/features/agents/extraction-agent-sessions/extraction-agent-sessions.slice" import type { formAgentSessionsSliceReducer } from "@/features/agents/form-agent-sessions/form-agent-sessions.slice" import type { agentSessionMessagesSliceReducer } from "@/features/agents/shared/agent-session-messages/agent-session-messages.slice" +import type { analyticsSliceReducer } from "@/features/analytics/analytics.slice" import type { authSliceReducer } from "@/features/auth/auth.slice" import type { documentTagsSliceReducer } from "@/features/document-tags/document-tags.slice" import type { documentsSliceReducer } from "@/features/documents/documents.slice" @@ -22,6 +23,7 @@ import type { projectsSliceReducer } from "@/features/projects/projects.slice" // Define the store state structure without creating the store // This allows us to use these types in listenerMiddleware without circular dependencies export type RootState = { + analytics: ReturnType agentMemberships: ReturnType agentMessageFeedback: ReturnType agents: ReturnType diff --git a/package-lock.json b/package-lock.json index e711d3e..79ee19f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,6 +134,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.11.0", "react-speech-recognition": "^4.0.1", + "recharts": "^3.8.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", @@ -141,7 +142,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@storybook/react-vite": "^10.1.10", + "@storybook/react-vite": "^10.3.3", "@types/lodash": "^4.17.23", "@types/node": "^24.10.1", "@types/react": "^19.2.5", @@ -152,7 +153,7 @@ "babel-plugin-react-compiler": "^1.0.0", "baseline-browser-mapping": "^2.9.11", "globals": "^16.5.0", - "storybook": "^10.1.10", + "storybook": "^10.3.3", "storybook-addon-remix-react-router": "^6.0.0", "typescript": "~5.9.3", "vite": "^7.2.4", @@ -1282,7 +1283,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -1295,13 +1296,19 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -1328,12 +1335,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1345,12 +1352,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1362,12 +1369,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1379,12 +1386,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1396,12 +1403,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1413,12 +1420,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1430,12 +1437,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1447,12 +1454,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1464,12 +1471,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1481,12 +1488,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1498,12 +1505,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1515,12 +1522,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1532,12 +1539,12 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1549,12 +1556,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1566,12 +1573,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1583,12 +1590,12 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1600,12 +1607,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1617,12 +1624,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1634,12 +1641,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1651,12 +1658,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1668,12 +1675,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1685,12 +1692,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -1702,12 +1709,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -1719,12 +1726,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1736,12 +1743,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1753,12 +1760,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4363,7 +4370,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -5130,7 +5137,7 @@ "version": "15.5.14", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz", "integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { @@ -7760,12 +7767,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -7774,12 +7781,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -7788,12 +7795,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -7802,12 +7809,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -7816,12 +7823,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -7830,12 +7837,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -7844,12 +7851,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -7858,12 +7865,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -7872,12 +7879,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -7886,12 +7893,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -7900,12 +7907,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -7914,12 +7921,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -7928,12 +7935,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -7942,12 +7949,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -7956,12 +7963,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -7970,12 +7977,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -7984,12 +7991,12 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -7998,12 +8005,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -8012,12 +8019,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -8026,12 +8033,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -8040,12 +8047,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -8054,12 +8061,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -8068,12 +8075,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -8082,12 +8089,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -8096,12 +8103,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", @@ -9447,12 +9454,21 @@ "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" } }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@tabler/icons": { "version": "3.41.1", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz", @@ -9939,28 +9955,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@turbo/darwin-64": { @@ -10144,6 +10160,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -10425,7 +10504,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -10435,7 +10513,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -11212,7 +11290,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -11429,7 +11507,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -12121,7 +12199,7 @@ "version": "1.0.30001784", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -12408,7 +12486,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cliui": { @@ -12832,7 +12910,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cron-parser": { @@ -12872,7 +12950,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/csv-parse": { @@ -12881,6 +12958,127 @@ "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -12900,6 +13098,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -12923,6 +13127,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -13151,7 +13361,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -13499,11 +13709,20 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -13687,6 +13906,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -13909,7 +14134,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -14277,7 +14501,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -15217,6 +15440,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ioredis": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", @@ -18770,7 +19002,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -20070,7 +20302,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -20112,7 +20343,7 @@ "version": "15.5.14", "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz", "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@next/env": "15.5.14", @@ -20175,7 +20406,7 @@ "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -20910,14 +21141,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -20985,7 +21214,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -21435,6 +21663,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-docgen": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", @@ -21553,7 +21803,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -21803,6 +22052,46 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -22185,7 +22474,6 @@ "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -22741,7 +23029,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -22752,7 +23040,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -23273,7 +23561,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "client-only": "0.0.1" @@ -23501,7 +23789,7 @@ "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -23636,7 +23924,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/test-exclude": { @@ -23680,7 +23968,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -23704,7 +23991,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -23951,7 +24237,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -24331,7 +24617,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -24659,7 +24945,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -24736,11 +25022,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -25451,7 +25758,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -25614,6 +25921,7 @@ "lucide-react": "^0.576.0", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", "react-hook-form": "^7.69.0", "tailwind-merge": "^3.4.0", diff --git a/packages/api-contracts/src/analytics/analytics.dto.ts b/packages/api-contracts/src/analytics/analytics.dto.ts new file mode 100644 index 0000000..b9f1eca --- /dev/null +++ b/packages/api-contracts/src/analytics/analytics.dto.ts @@ -0,0 +1,11 @@ +import type { TimeType } from "../generic" + +export type AnalyticsDateRangeRequestDto = { + startAt: TimeType + endAt: TimeType +} + +export type AnalyticsDailyPointDto = { + date: string + value: number +} diff --git a/packages/api-contracts/src/analytics/analytics.routes.ts b/packages/api-contracts/src/analytics/analytics.routes.ts new file mode 100644 index 0000000..b1e039d --- /dev/null +++ b/packages/api-contracts/src/analytics/analytics.routes.ts @@ -0,0 +1,16 @@ +import type { ResponseData } from "../generic" +import { defineRoute } from "../helpers" +import type { AnalyticsDailyPointDto } from "./analytics.dto" + +/** Query: `startAt`, `endAt` — Unix ms (see `AnalyticsDateRangeRequestDto`). */ +export const AnalyticsRoutes = { + getConversationsPerDay: defineRoute>({ + method: "get", + path: "organizations/:organizationId/projects/:projectId/analytics/conversations-per-day", + }), + + getAvgUserQuestionsPerSessionPerDay: defineRoute>({ + method: "get", + path: "organizations/:organizationId/projects/:projectId/analytics/avg-user-questions-per-session-per-day", + }), +} diff --git a/packages/api-contracts/src/feature-flags/feature-flags.dto.ts b/packages/api-contracts/src/feature-flags/feature-flags.dto.ts index 6038815..50d5971 100644 --- a/packages/api-contracts/src/feature-flags/feature-flags.dto.ts +++ b/packages/api-contracts/src/feature-flags/feature-flags.dto.ts @@ -12,6 +12,10 @@ export const FeatureFlags = [ key: "gemma", description: "Access and utilize gemma models.", }, + { + key: "project-analytics", + description: "View project-level analytics and usage charts in the studio.", + }, ] as const export type FeatureFlagKey = (typeof FeatureFlags)[number]["key"] export type FeatureFlagsDto = FeatureFlagKey[] diff --git a/packages/api-contracts/src/index.ts b/packages/api-contracts/src/index.ts index 577283c..2a01c39 100644 --- a/packages/api-contracts/src/index.ts +++ b/packages/api-contracts/src/index.ts @@ -25,51 +25,41 @@ export { FormAgentSessionsRoutes } from "./agents/form-agent-sessions/form-agent // Agent Session Messages export * from "./agents/shared/agent-session-messages/agent-session-messages.dto" export { AgentSessionMessagesRoutes } from "./agents/shared/agent-session-messages/agent-session-messages.routes" - +// Analytics +export type * from "./analytics/analytics.dto" +export { AnalyticsRoutes } from "./analytics/analytics.routes" // Document Tags export * from "./document-tags/document-tag.dto" export { DocumentTagsRoutes } from "./document-tags/document-tag.routes" - // Documents export * from "./documents/documents.dto" export { DocumentsRoutes } from "./documents/documents.routes" - // Evaluation Reports export * from "./evaluations/evaluation-reports.dto" export { EvaluationReportsRoutes } from "./evaluations/evaluation-reports.routes" - // Evaluations export * from "./evaluations/evaluations.dto" export { EvaluationsRoutes } from "./evaluations/evaluations.routes" - // Feature Flags export type * from "./feature-flags/feature-flags.dto" export { FeatureFlagsRoutes } from "./feature-flags/feature-flags.routes" - // Generic export type * from "./generic" export type { ApiRoute } from "./helpers" - // Helpers export { defineRoute } from "./helpers" - // Invitations export { InvitationsRoutes } from "./invitations/invitations.routes" - // Me export type { MeResponseDto } from "./me/me.dto" - // Routes export { MeRoutes } from "./me/me.routes" - // Organizations export type * from "./organizations/organizations.dto" export { OrganizationsRoutes } from "./organizations/organizations.routes" - // Project Membership export type * from "./project-membership/project-membership.dto" export { ProjectMembershipRoutes } from "./project-membership/project-membership.routes" - // Projects export type * from "./projects/projects.dto" export { ProjectsRoutes } from "./projects/projects.routes" diff --git a/packages/ui/package.json b/packages/ui/package.json index e1b03d9..a588b22 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,6 +4,11 @@ "private": true, "exports": { "./utils": "./src/lib/utils.ts", + "./lib/calendar-date-helpers": "./src/lib/calendar-date-helpers.ts", + "./lib/date-range-presets": "./src/lib/date-range-presets.ts", + "./components/ChartCard": "./src/components/ChartCard.tsx", + "./components/DateRangeCalendarWithPresets": "./src/components/DateRangeCalendarWithPresets.tsx", + "./shad/calendar": "./src/shad/calendar.tsx", "./*": "./src/*.tsx", "./components/layouts/sidebar/types": "./src/components/layouts/sidebar/types.ts" }, @@ -57,6 +62,7 @@ "lucide-react": "^0.576.0", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", "react-hook-form": "^7.69.0", "tailwind-merge": "^3.4.0", diff --git a/packages/ui/src/components/ChartCard.tsx b/packages/ui/src/components/ChartCard.tsx new file mode 100644 index 0000000..284fae6 --- /dev/null +++ b/packages/ui/src/components/ChartCard.tsx @@ -0,0 +1,114 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@caseai-connect/ui/shad/card" +import { useMemo } from "react" +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" +import type { ValueType } from "recharts/types/component/DefaultTooltipContent" + +export type DailyMetricPoint = { + date: string + value: number +} + +export type ChartCardGetSummaryValue = (data: DailyMetricPoint[]) => number + +type ChartCardProps = { + title: string + description?: string + metricLabel: string + data: DailyMetricPoint[] + /** How to derive the headline number from the series (e.g. sum for counts, mean for daily averages). */ + getSummaryValue: ChartCardGetSummaryValue +} + +function formatDateTick(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +function formatTooltipLabel(labelValue: React.ReactNode): string { + const safeDate = new Date(String(labelValue)) + return safeDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function formatTooltipNumber(value: ValueType | undefined): string { + return value?.toLocaleString() ?? "N/A" +} + +function formatYAxisTick(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString() + } + return value.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 0 }) +} + +function formatSummaryNumber(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString() + } + return value.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 0 }) +} + +function ChartCard({ title, description, metricLabel, data, getSummaryValue }: ChartCardProps) { + const summaryValue = useMemo(() => getSummaryValue(data), [data, getSummaryValue]) + + return ( + + +
+ {title} + {description && {description}} +
+
+
+ {metricLabel} + + {formatSummaryNumber(summaryValue)} + +
+
+
+ +
+ + + + + + [formatTooltipNumber(value), metricLabel]} + labelFormatter={(labelValue) => formatTooltipLabel(labelValue)} + /> + + + +
+
+
+ ) +} + +export default ChartCard diff --git a/packages/ui/src/components/DateRangeCalendarWithPresets.tsx b/packages/ui/src/components/DateRangeCalendarWithPresets.tsx new file mode 100644 index 0000000..10d91ab --- /dev/null +++ b/packages/ui/src/components/DateRangeCalendarWithPresets.tsx @@ -0,0 +1,256 @@ +"use client" + +import { CalendarDays } from "lucide-react" +import { useCallback, useState } from "react" +import type { DateRange } from "react-day-picker" + +import { isCalendarDayAfterToday } from "../lib/calendar-date-helpers" +import { getLast7DaysRange, getLast30DaysRange } from "../lib/date-range-presets" +import { cn } from "../lib/utils" +import { Button } from "../shad/button" +import { Calendar } from "../shad/calendar" +import { Card, CardContent, CardFooter } from "../shad/card" +import { Popover, PopoverContent, PopoverTrigger } from "../shad/popover" + +export type DateRangePreset = "last7Days" | "last30Days" | "custom" + +export type BuiltInDateRangePreset = "last7Days" | "last30Days" + +export function formatDateRangeLabel(range: DateRange | undefined): string | undefined { + if (!range?.from) { + return undefined + } + const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" } + const fromLabel = range.from.toLocaleDateString(undefined, options) + if (!range.to) { + return fromLabel + } + const toLabel = range.to.toLocaleDateString(undefined, options) + return `${fromLabel} – ${toLabel}` +} + +function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1) +} + +type RangeCalendarState = { + range: DateRange | undefined + activePreset: DateRangePreset + month: Date +} + +function buildInitialRangeCalendarState(defaultPreset: BuiltInDateRangePreset): RangeCalendarState { + const range = defaultPreset === "last30Days" ? getLast30DaysRange() : getLast7DaysRange() + const anchor = range.from ?? new Date() + return { + range, + activePreset: defaultPreset, + month: startOfMonth(anchor), + } +} + +function useDateRangeWithPresets( + defaultPreset: BuiltInDateRangePreset, + onRangeChange?: (range: DateRange | undefined, preset: DateRangePreset) => void, +) { + const [state, setState] = useState(() => + buildInitialRangeCalendarState(defaultPreset), + ) + + const applyPreset = useCallback( + (preset: BuiltInDateRangePreset) => { + const nextRange = preset === "last30Days" ? getLast30DaysRange() : getLast7DaysRange() + const anchor = nextRange.from ?? new Date() + setState({ + range: nextRange, + activePreset: preset, + month: startOfMonth(anchor), + }) + onRangeChange?.(nextRange, preset) + }, + [onRangeChange], + ) + + const handleSelect = useCallback( + (nextRange: DateRange | undefined) => { + setState((previous) => ({ + range: nextRange, + activePreset: "custom", + month: nextRange?.from ? startOfMonth(nextRange.from) : previous.month, + })) + onRangeChange?.(nextRange, "custom") + }, + [onRangeChange], + ) + + const handleMonthChange = useCallback((nextMonth: Date) => { + setState((previous) => ({ ...previous, month: nextMonth })) + }, []) + + return { state, applyPreset, handleSelect, handleMonthChange } +} + +type DateRangeCalendarWithPresetsCardBodyProps = { + state: RangeCalendarState + applyPreset: (preset: BuiltInDateRangePreset) => void + handleSelect: (range: DateRange | undefined) => void + handleMonthChange: (month: Date) => void + numberOfMonths: number + calendarClassName?: string +} + +function DateRangeCalendarWithPresetsCardBody({ + state, + applyPreset, + handleSelect, + handleMonthChange, + numberOfMonths, + calendarClassName, +}: DateRangeCalendarWithPresetsCardBodyProps) { + return ( + <> + + + + + + + + + ) +} + +export type DateRangeCalendarWithPresetsProps = { + defaultPreset?: BuiltInDateRangePreset + onRangeChange?: (range: DateRange | undefined, preset: DateRangePreset) => void + className?: string + numberOfMonths?: number + calendarClassName?: string +} + +export function DateRangeCalendarWithPresets({ + defaultPreset = "last7Days", + onRangeChange, + className, + numberOfMonths = 2, + calendarClassName, +}: DateRangeCalendarWithPresetsProps) { + const { state, applyPreset, handleSelect, handleMonthChange } = useDateRangeWithPresets( + defaultPreset, + onRangeChange, + ) + + return ( + + + + ) +} + +export type DateRangeCalendarWithPresetsPopoverProps = { + defaultPreset?: BuiltInDateRangePreset + onRangeChange?: (range: DateRange | undefined, preset: DateRangePreset) => void + numberOfMonths?: number + calendarClassName?: string + /** Shown on the trigger when there is no range label. */ + placeholder?: string + align?: "start" | "center" | "end" + triggerClassName?: string + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function DateRangeCalendarWithPresetsPopover({ + defaultPreset = "last7Days", + onRangeChange, + numberOfMonths = 2, + calendarClassName, + placeholder = "Pick a date range", + align = "start", + triggerClassName, + open: controlledOpen, + onOpenChange, +}: DateRangeCalendarWithPresetsPopoverProps) { + const { state, applyPreset, handleSelect, handleMonthChange } = useDateRangeWithPresets( + defaultPreset, + onRangeChange, + ) + + const [internalOpen, setInternalOpen] = useState(false) + const open = controlledOpen ?? internalOpen + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange?.(nextOpen) + if (controlledOpen === undefined) { + setInternalOpen(nextOpen) + } + }, + [controlledOpen, onOpenChange], + ) + + return ( + + + + + + + + + + + ) +} diff --git a/packages/ui/src/global.css b/packages/ui/src/global.css index 59a5fe5..9ddb984 100644 --- a/packages/ui/src/global.css +++ b/packages/ui/src/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "react-day-picker/style.css"; @custom-variant dark (&:is(.dark *)); @@ -94,6 +95,7 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --color-chart-primary: var(--primary); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); @@ -131,3 +133,49 @@ text-transform: uppercase; } } + +/* + * React DayPicker ships unlayered CSS. Declarations inside @layer base lose to it. + * Keep these overrides unlayered and after @import "react-day-picker/style.css" in this file + * so they win on equal specificity (.rdp-root). + */ +.rdp-root { + --rdp-accent-color: var(--primary); + --rdp-accent-background-color: color-mix(in oklab, var(--primary) 16%, transparent); + --rdp-range_middle-background-color: color-mix(in oklab, var(--primary) 14%, transparent); + --rdp-range_start-date-background-color: var(--primary); + --rdp-range_end-date-background-color: var(--primary); + --rdp-range_start-color: var(--primary-foreground); + --rdp-range_end-color: var(--primary-foreground); + --rdp-today-color: var(--primary); + + /* Compact grid + range caps (library defaults: 42px circles + large selected type) */ + --rdp-day-height: 2rem; + --rdp-day-width: 2rem; + --rdp-day_button-height: 1.625rem; + --rdp-day_button-width: 1.625rem; + --rdp-day_button-border-radius: 0.3125rem; + --rdp-selected-border: none; +} + +/* Library default: .rdp-selected { font-weight: bold; font-size: large } */ +.rdp-root .rdp-selected { + font-size: 0.75rem; + line-height: 1; + font-weight: 500; +} + +.rdp-root .rdp-selected .rdp-day_button { + font-size: inherit; + font-weight: inherit; +} + +.rdp-root .rdp-button_next, +.rdp-root .rdp-button_previous { + color: var(--primary); +} + +.rdp-root .rdp-button_next:not(:disabled):not([aria-disabled="true"]):hover, +.rdp-root .rdp-button_previous:not(:disabled):not([aria-disabled="true"]):hover { + color: color-mix(in oklab, var(--primary) 88%, transparent); +} diff --git a/packages/ui/src/lib/calendar-date-helpers.ts b/packages/ui/src/lib/calendar-date-helpers.ts new file mode 100644 index 0000000..268ccd4 --- /dev/null +++ b/packages/ui/src/lib/calendar-date-helpers.ts @@ -0,0 +1,11 @@ +/** Start of the given calendar day in local time. */ +export function startOfLocalDay(date: Date): Date { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +/** True when `date` is strictly after today’s calendar day (local). */ +export function isCalendarDayAfterToday(date: Date): boolean { + return startOfLocalDay(date) > startOfLocalDay(new Date()) +} diff --git a/packages/ui/src/lib/date-range-presets.ts b/packages/ui/src/lib/date-range-presets.ts new file mode 100644 index 0000000..c738237 --- /dev/null +++ b/packages/ui/src/lib/date-range-presets.ts @@ -0,0 +1,29 @@ +import type { DateRange } from "react-day-picker" + +function startOfLocalDay(date: Date): Date { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +/** + * Inclusive rolling window of calendar days ending on `referenceDate` (local). + * Example: dayCount 7 → today and the six previous days. + */ +export function getInclusiveRollingDayRange( + dayCount: number, + referenceDate: Date = new Date(), +): DateRange { + const to = startOfLocalDay(referenceDate) + const from = startOfLocalDay(referenceDate) + from.setDate(from.getDate() - (dayCount - 1)) + return { from, to } +} + +export function getLast7DaysRange(referenceDate?: Date): DateRange { + return getInclusiveRollingDayRange(7, referenceDate) +} + +export function getLast30DaysRange(referenceDate?: Date): DateRange { + return getInclusiveRollingDayRange(30, referenceDate) +} diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 4a02803..5921d17 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,8 +1,31 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" +function normalizeImportantSuffixToken(token: string): string { + if (!token.endsWith("!") || token.startsWith("!")) { + return token + } + + const tokenWithoutImportantSuffix = token.slice(0, -1) + const tokenParts = tokenWithoutImportantSuffix.split(":") + const utilityToken = tokenParts.pop() + if (!utilityToken || utilityToken.startsWith("!")) { + return token + } + + return [...tokenParts, `!${utilityToken}`].join(":") +} + +function normalizeImportantSuffixClasses(input: string): string { + return input + .split(/\s+/) + .filter(Boolean) + .map((token) => normalizeImportantSuffixToken(token)) + .join(" ") +} + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(normalizeImportantSuffixClasses(clsx(inputs))) } // Can be used to hash a string to a unique identifier (in order to avoid using the index as a key in a React list) diff --git a/packages/ui/src/shad/calendar.tsx b/packages/ui/src/shad/calendar.tsx new file mode 100644 index 0000000..af94ccb --- /dev/null +++ b/packages/ui/src/shad/calendar.tsx @@ -0,0 +1,56 @@ +"use client" + +import type { DayPickerProps } from "react-day-picker" +import { DayPicker, getDefaultClassNames } from "react-day-picker" + +import { cn } from "../lib/utils" + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: DayPickerProps) { + const defaultClassNames = getDefaultClassNames() + + return ( + + ) +} + +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/packages/ui/src/stories/ChartCard.stories.tsx b/packages/ui/src/stories/ChartCard.stories.tsx new file mode 100644 index 0000000..254a6f1 --- /dev/null +++ b/packages/ui/src/stories/ChartCard.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react" +import type { DailyMetricPoint } from "@/components/ChartCard" +import ChartCard from "@/components/ChartCard" + +type StoryArgs = { + title: string + metricLabel: string +} + +const meta: Meta = { + title: "UI/ChartCard", + parameters: { layout: "padded" }, +} + +export default meta +type Story = StoryObj + +const chartData: DailyMetricPoint[] = [ + { date: "2026-01-01", value: 34 }, + { date: "2026-01-02", value: 21 }, + { date: "2026-01-03", value: 47 }, + { date: "2026-01-04", value: 55 }, + { date: "2026-01-05", value: 31 }, + { date: "2026-01-06", value: 62 }, + { date: "2026-01-07", value: 39 }, + { date: "2026-01-08", value: 44 }, + { date: "2026-01-09", value: 28 }, + { date: "2026-01-10", value: 67 }, + { date: "2026-01-11", value: 53 }, + { date: "2026-01-12", value: 36 }, + { date: "2026-01-13", value: 75 }, + { date: "2026-01-14", value: 49 }, + { date: "2026-01-15", value: 58 }, + { date: "2026-01-16", value: 30 }, + { date: "2026-01-17", value: 82 }, + { date: "2026-01-18", value: 71 }, + { date: "2026-01-19", value: 43 }, + { date: "2026-01-20", value: 51 }, + { date: "2026-01-21", value: 64 }, + { date: "2026-01-22", value: 27 }, + { date: "2026-01-23", value: 59 }, + { date: "2026-01-24", value: 46 }, + { date: "2026-01-25", value: 40 }, + { date: "2026-01-26", value: 69 }, + { date: "2026-01-27", value: 33 }, + { date: "2026-01-28", value: 57 }, + { date: "2026-01-29", value: 48 }, + { date: "2026-01-30", value: 77 }, +] + +/** Series stays outside `args` so Storybook does not traverse/freeze it (avoids HMR readonly errors). */ +export const ChartCardExample: Story = { + args: { + title: "Conversations per day", + metricLabel: "Conversations", + }, + render: (args) => ( +
+ series.reduce((sum, point) => sum + point.value, 0)} + description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + /> +
+ ), +} diff --git a/packages/ui/src/stories/DateRangeCalendarWithPresets.stories.tsx b/packages/ui/src/stories/DateRangeCalendarWithPresets.stories.tsx new file mode 100644 index 0000000..49622d6 --- /dev/null +++ b/packages/ui/src/stories/DateRangeCalendarWithPresets.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { fn } from "@storybook/test" + +import { + DateRangeCalendarWithPresets, + DateRangeCalendarWithPresetsPopover, +} from "@/components/DateRangeCalendarWithPresets" + +const meta = { + title: "UI/DateRangeCalendarWithPresets", + component: DateRangeCalendarWithPresets, + parameters: { layout: "padded" }, + args: { + onRangeChange: fn(), + numberOfMonths: 2, + defaultPreset: "last7Days" as const, + }, + argTypes: { + defaultPreset: { + control: "select", + options: ["last7Days", "last30Days"], + }, + numberOfMonths: { control: { type: "number", min: 1, max: 3 } }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const InlineCard: Story = { + name: "Inline card", + args: { + defaultPreset: "last7Days", + }, +} + +export const InlineCardLast30: Story = { + name: "Inline card (last 30 default)", + args: { + defaultPreset: "last30Days", + }, +} + +export const InlineSingleMonth: Story = { + name: "Inline card (single month)", + args: { + defaultPreset: "last7Days", + numberOfMonths: 1, + }, +} + +export const InPopoverDropdown: StoryObj = { + name: "In popover (dropdown)", + render: (args) => ( +
+ +
+ ), + args: { + defaultPreset: "last7Days", + numberOfMonths: 2, + placeholder: "Pick a date range", + }, + argTypes: { + defaultPreset: { + control: "select", + options: ["last7Days", "last30Days"], + }, + numberOfMonths: { control: { type: "number", min: 1, max: 3 } }, + }, +}