-
+
+
+
+
+
+ {isConnected && (
+
+
+ Connected
+
+
+ )}
{integration.name}
{integration.description || `Connect to ${integration.name}`}
- {isConnected && (
-
-
-
- Connected
-
-
-
- )}
);
diff --git a/apps/webapp/app/components/integrations/utils.tsx b/apps/webapp/app/components/integrations/utils.tsx
new file mode 100644
index 0000000..05ac9b3
--- /dev/null
+++ b/apps/webapp/app/components/integrations/utils.tsx
@@ -0,0 +1,34 @@
+export const FIXED_INTEGRATIONS = [
+ {
+ id: "claude",
+ name: "Claude",
+ description: "AI assistant for coding, writing, and analysis",
+ icon: "claude",
+ slug: "claude",
+ spec: {},
+ },
+ {
+ id: "cursor",
+ name: "Cursor",
+ description: "AI-powered code editor",
+ icon: "cursor",
+ slug: "cursor",
+ spec: {},
+ },
+ {
+ id: "cline",
+ name: "Cline",
+ description: "AI coding assistant for terminal and command line",
+ icon: "cline",
+ slug: "cline",
+ spec: {},
+ },
+ {
+ id: "vscode",
+ name: "Visual Studio Code",
+ description: "Popular code editor with extensive extensions",
+ icon: "vscode",
+ slug: "vscode",
+ spec: {},
+ },
+];
diff --git a/apps/webapp/app/components/logs/log-text-collapse.tsx b/apps/webapp/app/components/logs/log-text-collapse.tsx
index dfc6123..d1f74b4 100644
--- a/apps/webapp/app/components/logs/log-text-collapse.tsx
+++ b/apps/webapp/app/components/logs/log-text-collapse.tsx
@@ -4,6 +4,7 @@ import { AlertCircle, Info, Trash } from "lucide-react";
import { cn } from "~/lib/utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import { Button } from "../ui";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import {
AlertDialog,
AlertDialogAction,
@@ -15,21 +16,42 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
+import { Badge } from "../ui/badge";
+import { LogItem } from "~/hooks/use-logs";
interface LogTextCollapseProps {
text?: string;
error?: string;
logData: any;
+ log: LogItem;
id: string;
episodeUUID?: string;
}
+const getStatusColor = (status: string) => {
+ switch (status) {
+ case "PROCESSING":
+ return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
+ case "PENDING":
+ return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
+ case "COMPLETED":
+ return "bg-success/10 text-success hover:bg-success/10 hover:text-success";
+ case "FAILED":
+ return "bg-destructive/10 text-destructive hover:bg-destructive/10 hover:text-destructive";
+ case "CANCELLED":
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
+ default:
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
+ }
+};
+
export function LogTextCollapse({
episodeUUID,
text,
error,
id,
logData,
+ log,
}: LogTextCollapseProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -75,19 +97,28 @@ export function LogTextCollapse({
}
return (
- <>
-
-
+
+
+ >
+
+
setDialogOpen(true)}
+ >
+
+
- {isLong && (
- <>
- >
- )}
-
-
- {isLong && (
-
-
- {episodeUUID && (
-
-
-
-
-
-
- Delete Episode
-
- Are you sure you want to delete this episode? This action
- cannot be undone.
-
-
-
- Cancel
-
- Continue
-
-
-
-
- )}
+
+
+
+ {log.status.charAt(0).toUpperCase() +
+ log.status.slice(1).toLowerCase()}
+
+
+
+ {new Date(log.time).toLocaleString()}
+
+
+
+ {episodeUUID && (
+
+
+
+
+
+
+ Delete Episode
+
+ Are you sure you want to delete this episode? This
+ action cannot be undone.
+
+
+
+ Cancel
+
+ Continue
+
+
+
+
+ )}
+
+
- )}
- {error && (
-
- )}
+
- >
+
);
}
diff --git a/apps/webapp/app/components/logs/logs-filters.tsx b/apps/webapp/app/components/logs/logs-filters.tsx
index 4d391f5..4ee1d47 100644
--- a/apps/webapp/app/components/logs/logs-filters.tsx
+++ b/apps/webapp/app/components/logs/logs-filters.tsx
@@ -51,7 +51,7 @@ export function LogsFilters({
const handleBack = () => setStep("main");
return (
- ();
+function CustomIntegrationContent({ integration }: { integration: any }) {
+ const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`;
+ const [copied, setCopied] = useState(false);
+ const copyToClipboard = async () => {
+ try {
+ await navigator.clipboard.writeText(memoryUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ };
+
+ const getCustomContent = () => {
+ switch (integration.id) {
+ case "claude":
+ return {
+ title: "About Claude",
+ content: (
+
+
+ Claude is an AI assistant created by Anthropic. It can help with
+ a wide variety of tasks including:
+
+
+ - Code generation and debugging
+ - Writing and editing
+ - Analysis and research
+ - Problem-solving
+
+
+
+ For Claude Web, Desktop, and Code - OAuth authentication handled
+ automatically
+
+
+
+
+
+
+
+ ),
+ };
+ case "cursor":
+ return {
+ title: "About Cursor",
+ content: (
+
+
+ Cursor is an AI-powered code editor that helps developers write
+ code faster and more efficiently.
+
+
+ - AI-powered code completion
+ - Natural language to code conversion
+ - Code explanation and debugging
+ - Refactoring assistance
+
+
+
+ {JSON.stringify(
+ {
+ memory: {
+ url: memoryUrl,
+ },
+ },
+ null,
+ 2,
+ )}
+
+
+
+
+ ),
+ };
+ case "cline":
+ return {
+ title: "About Cline",
+ content: (
+
+
+ Cline is an AI coding assistant that works directly in your
+ terminal and command line environment.
+
+
+ - Command line AI assistance
+ - Terminal-based code generation
+ - Shell script optimization
+ - DevOps automation help
+
+
+
+
+
+
+ ),
+ };
+ case "vscode":
+ return {
+ title: "About Visual Studio Code",
+ content: (
+
+
+ Visual Studio Code is a lightweight but powerful source code
+ editor with extensive extension support.
+
+
+ - Intelligent code completion
+ - Built-in Git integration
+ - Extensive extension marketplace
+ - Debugging and testing tools
+
+
You need to enable MCP in settings
+
+
+ {JSON.stringify(
+ {
+ "chat.mcp.enabled": true,
+ "chat.mcp.discovery.enabled": true,
+ },
+ null,
+ 2,
+ )}
+
+
+
+
+ {JSON.stringify(
+ {
+ memory: {
+ type: "http",
+ url: memoryUrl,
+ },
+ },
+ null,
+ 2,
+ )}
+
+
+
+
+ ),
+ };
+ default:
+ return null;
+ }
+ };
+
+ const customContent = getCustomContent();
+
+ if (!customContent) return null;
+ const Component = getIcon(integration.icon as IconType);
+
+ return (
+
+
,
+ onClick: () =>
+ window.open(
+ "https://github.com/redplanethq/core/issues/new",
+ "_blank",
+ ),
+ variant: "secondary",
+ },
+ ]}
+ />
+
+
+
+ }
+ >
+
{customContent.content}
+
+
+
+
+ );
+}
+
+interface IntegrationDetailProps {
+ integration: any;
+ integrationAccounts: any;
+ ingestionRule: any;
+}
+
+export function IntegrationDetail({
+ integration,
+ integrationAccounts,
+ ingestionRule,
+}: IntegrationDetailProps) {
const activeAccount = useMemo(
() =>
integrationAccounts.find(
- (acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
+ (acc: IntegrationAccount) =>
+ acc.integrationDefinitionId === integration.id && acc.isActive,
),
[integrationAccounts, integration.id],
);
@@ -181,21 +492,21 @@ export default function IntegrationDetail() {
{hasApiKey && (
-
+
API Key authentication
)}
{hasOAuth2 && (
-
+
OAuth 2.0 authentication
)}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
-
+
No authentication method specified
)}
@@ -226,7 +537,7 @@ export default function IntegrationDetail() {
)}
{/* Connected Account Info */}
-
+
{/* MCP Authentication Section */}
);
}
+
+export default function IntegrationDetailWrapper() {
+ const { integration, integrationAccounts, ingestionRule } =
+ useLoaderData();
+
+ const { slug } = useParams();
+ // You can now use the `slug` param in your component
+
+ const fixedIntegration = FIXED_INTEGRATIONS.some(
+ (fixedInt) => fixedInt.slug === slug,
+ );
+
+ return (
+ <>
+ {fixedIntegration ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/webapp/app/routes/home.integrations.tsx b/apps/webapp/app/routes/home.integrations.tsx
index 1a8af63..65a83e9 100644
--- a/apps/webapp/app/routes/home.integrations.tsx
+++ b/apps/webapp/app/routes/home.integrations.tsx
@@ -8,6 +8,7 @@ import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationGrid } from "~/components/integrations/integration-grid";
import { PageHeader } from "~/components/common/page-header";
import { Plus } from "lucide-react";
+import { FIXED_INTEGRATIONS } from "~/components/integrations/utils";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -18,8 +19,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
getIntegrationAccounts(userId),
]);
+ // Combine fixed integrations with dynamic ones
+ const allIntegrations = [...FIXED_INTEGRATIONS, ...integrationDefinitions];
+
return json({
- integrationDefinitions,
+ integrationDefinitions: allIntegrations,
integrationAccounts,
userId,
});
diff --git a/apps/webapp/app/routes/home.logs.all.tsx b/apps/webapp/app/routes/home.logs.all.tsx
index 439c520..dc96362 100644
--- a/apps/webapp/app/routes/home.logs.all.tsx
+++ b/apps/webapp/app/routes/home.logs.all.tsx
@@ -1,5 +1,5 @@
-import { useState } from "react";
-import { useNavigate } from "@remix-run/react";
+import { useState, useEffect } from "react";
+import { useNavigate, useFetcher } from "@remix-run/react";
import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
@@ -7,11 +7,13 @@ import { AppContainer, PageContainer } from "~/components/layout/app-layout";
import { Card, CardContent } from "~/components/ui/card";
import { Database, LoaderCircle } from "lucide-react";
import { PageHeader } from "~/components/common/page-header";
+import { ContributionGraph } from "~/components/activity/contribution-graph";
export default function LogsAll() {
const navigate = useNavigate();
const [selectedSource, setSelectedSource] = useState();
const [selectedStatus, setSelectedStatus] = useState();
+ const contributionFetcher = useFetcher();
const {
logs,
@@ -26,17 +28,41 @@ export default function LogsAll() {
status: selectedStatus,
});
+ // Fetch contribution data on mount
+ useEffect(() => {
+ if (contributionFetcher.state === "idle" && !contributionFetcher.data) {
+ contributionFetcher.load("/api/v1/activity/contribution");
+ }
+ }, [contributionFetcher]);
+
+ // Get contribution data from fetcher
+ const contributionData = contributionFetcher.data?.success
+ ? contributionFetcher.data.data.contributionData
+ : [];
+ const totalActivities = contributionFetcher.data?.success
+ ? contributionFetcher.data.data.totalActivities
+ : 0;
+ const isContributionLoading =
+ contributionFetcher.state === "loading" || !contributionFetcher.data;
+
return (
<>
-
+
+ {/* Contribution Graph */}
+
+ {isContributionLoading ? (
+
+ ) : (
+
+ )}
+
{isInitialLoad ? (
<>
-
{" "}
+
>
) : (
<>
- {" "}
{/* Filters */}
{logs.length > 0 && (
= {
+ params?: TParamsSchema;
+ searchParams?: TSearchParamsSchema;
+ headers?: THeadersSchema;
+ allowJWT?: boolean;
+ corsStrategy?: "all" | "none";
+ findResource: (
+ params: TParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ authentication: HybridAuthenticationResult,
+ searchParams: TSearchParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ ) => Promise;
+ shouldRetryNotFound?: boolean;
+ authorization?: {
+ action: AuthorizationAction;
+ resource: (
+ resource: NonNullable,
+ params: TParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ searchParams: TSearchParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ headers: THeadersSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ ) => AuthorizationResources;
+ superScopes?: string[];
+ };
+};
+
+type HybridLoaderHandlerFunction<
+ TParamsSchema extends AnyZodSchema | undefined,
+ TSearchParamsSchema extends AnyZodSchema | undefined,
+ THeadersSchema extends AnyZodSchema | undefined = undefined,
+ TResource = never,
+> = (args: {
+ params: TParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ searchParams: TSearchParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ headers: THeadersSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ authentication: HybridAuthenticationResult;
+ request: Request;
+ resource: NonNullable;
+}) => Promise;
+
+export function createHybridLoaderApiRoute<
+ TParamsSchema extends AnyZodSchema | undefined = undefined,
+ TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
+ THeadersSchema extends AnyZodSchema | undefined = undefined,
+ TResource = never,
+>(
+ options: HybridLoaderRouteBuilderOptions<
+ TParamsSchema,
+ TSearchParamsSchema,
+ THeadersSchema,
+ TResource
+ >,
+ handler: HybridLoaderHandlerFunction<
+ TParamsSchema,
+ TSearchParamsSchema,
+ THeadersSchema,
+ TResource
+ >,
+) {
+ return async function loader({ request, params }: LoaderFunctionArgs) {
+ const {
+ params: paramsSchema,
+ searchParams: searchParamsSchema,
+ headers: headersSchema,
+ allowJWT = false,
+ corsStrategy = "none",
+ authorization,
+ findResource,
+ shouldRetryNotFound,
+ } = options;
+
+ if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
+ return apiCors(request, json({}));
+ }
+
+ try {
+ const authenticationResult = await authenticateHybridRequest(request, {
+ allowJWT,
+ });
+
+ if (!authenticationResult) {
+ return await wrapResponse(
+ request,
+ json({ error: "Authentication required" }, { status: 401 }),
+ corsStrategy !== "none",
+ );
+ }
+
+ let parsedParams: any = undefined;
+ if (paramsSchema) {
+ const parsed = paramsSchema.safeParse(params);
+ if (!parsed.success) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Params Error",
+ details: fromZodError(parsed.error).details,
+ },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedParams = parsed.data;
+ }
+
+ let parsedSearchParams: any = undefined;
+ if (searchParamsSchema) {
+ const searchParams = Object.fromEntries(
+ new URL(request.url).searchParams,
+ );
+ const parsed = searchParamsSchema.safeParse(searchParams);
+ if (!parsed.success) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Query Error",
+ details: fromZodError(parsed.error).details,
+ },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedSearchParams = parsed.data;
+ }
+
+ let parsedHeaders: any = undefined;
+ if (headersSchema) {
+ const rawHeaders = Object.fromEntries(request.headers);
+ const headers = headersSchema.safeParse(rawHeaders);
+ if (!headers.success) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Headers Error",
+ details: fromZodError(headers.error).details,
+ },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedHeaders = headers.data;
+ }
+
+ // Find the resource
+ const resource = await findResource(
+ parsedParams,
+ authenticationResult,
+ parsedSearchParams,
+ );
+
+ if (!resource) {
+ return await wrapResponse(
+ request,
+ json(
+ { error: "Not found" },
+ {
+ status: 404,
+ headers: {
+ "x-should-retry": shouldRetryNotFound ? "true" : "false",
+ },
+ },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+
+ // Authorization check - only applies to API key authentication
+ if (authorization && authenticationResult.type === "PRIVATE") {
+ const { action, resource: authResource, superScopes } = authorization;
+ const $authResource = authResource(
+ resource,
+ parsedParams,
+ parsedSearchParams,
+ parsedHeaders,
+ );
+
+ logger.debug("Checking authorization", {
+ action,
+ resource: $authResource,
+ superScopes,
+ scopes: authenticationResult.scopes,
+ });
+
+ const authorizationResult = checkAuthorization(authenticationResult);
+
+ if (!authorizationResult.authorized) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: `Unauthorized: ${authorizationResult.reason}`,
+ code: "unauthorized",
+ param: "access_token",
+ type: "authorization",
+ },
+ { status: 403 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ }
+
+ const result = await handler({
+ params: parsedParams,
+ searchParams: parsedSearchParams,
+ headers: parsedHeaders,
+ authentication: authenticationResult,
+ request,
+ resource,
+ });
+ return await wrapResponse(request, result, corsStrategy !== "none");
+ } catch (error) {
+ try {
+ if (error instanceof Response) {
+ return await wrapResponse(request, error, corsStrategy !== "none");
+ }
+
+ logger.error("Error in hybrid loader", {
+ error:
+ error instanceof Error
+ ? {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ }
+ : String(error),
+ url: request.url,
+ });
+
+ return await wrapResponse(
+ request,
+ json({ error: "Internal Server Error" }, { status: 500 }),
+ corsStrategy !== "none",
+ );
+ } catch (innerError) {
+ logger.error("[apiBuilder] Failed to handle error", {
+ error,
+ innerError,
+ });
+
+ return json({ error: "Internal Server Error" }, { status: 500 });
+ }
+ }
+ };
+}
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 80f78c5..d4921d2 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -465,6 +465,27 @@
}
@layer base {
+ .react-calendar-heatmap {
+ font-size: 9px;
+ }
+ .react-calendar-heatmap .react-calendar-heatmap-month-label {
+ font-size: 9px;
+ fill: hsl(var(--muted-foreground));
+ }
+ .react-calendar-heatmap .react-calendar-heatmap-weekday-label {
+ font-size: 9px;
+ fill: hsl(var(--muted-foreground));
+ }
+ .react-calendar-heatmap rect {
+
+ rx: 2;
+ }
+ .react-calendar-heatmap rect:hover {
+
+ }
+
+
+
.tiptap {
:first-child {
margin-top: 0;
@@ -535,3 +556,4 @@
}
}
}
+
diff --git a/apps/webapp/app/trigger/cluster/index.ts b/apps/webapp/app/trigger/cluster/index.ts
new file mode 100644
index 0000000..7a62d93
--- /dev/null
+++ b/apps/webapp/app/trigger/cluster/index.ts
@@ -0,0 +1,115 @@
+import { queue, task } from "@trigger.dev/sdk";
+import { z } from "zod";
+import { ClusteringService } from "~/services/clustering.server";
+import { logger } from "~/services/logger.service";
+
+const clusteringService = new ClusteringService();
+
+// Define the payload schema for cluster tasks
+export const ClusterPayload = z.object({
+ userId: z.string(),
+ mode: z.enum(["auto", "incremental", "complete", "drift"]).default("auto"),
+ forceComplete: z.boolean().default(false),
+});
+
+const clusterQueue = queue({
+ name: "cluster-queue",
+ concurrencyLimit: 10,
+});
+
+/**
+ * Single clustering task that handles all clustering operations based on payload mode
+ */
+export const clusterTask = task({
+ id: "cluster",
+ queue: clusterQueue,
+ maxDuration: 1800, // 30 minutes max
+ run: async (payload: z.infer) => {
+ logger.info(`Starting ${payload.mode} clustering task for user ${payload.userId}`);
+
+ try {
+ let result;
+
+ switch (payload.mode) {
+ case "incremental":
+ result = await clusteringService.performIncrementalClustering(
+ payload.userId,
+ );
+ logger.info(`Incremental clustering completed for user ${payload.userId}:`, {
+ newStatementsProcessed: result.newStatementsProcessed,
+ newClustersCreated: result.newClustersCreated,
+ });
+ break;
+
+ case "complete":
+ result = await clusteringService.performCompleteClustering(
+ payload.userId,
+ );
+ logger.info(`Complete clustering completed for user ${payload.userId}:`, {
+ clustersCreated: result.clustersCreated,
+ statementsProcessed: result.statementsProcessed,
+ });
+ break;
+
+ case "drift":
+ // First detect drift
+ const driftMetrics = await clusteringService.detectClusterDrift(
+ payload.userId,
+ );
+
+ if (driftMetrics.driftDetected) {
+ // Handle drift by splitting low-cohesion clusters
+ const driftResult = await clusteringService.handleClusterDrift(
+ payload.userId,
+ );
+
+ logger.info(`Cluster drift handling completed for user ${payload.userId}:`, {
+ driftDetected: true,
+ clustersProcessed: driftResult.clustersProcessed,
+ newClustersCreated: driftResult.newClustersCreated,
+ splitClusters: driftResult.splitClusters,
+ });
+
+ result = {
+ driftDetected: true,
+ ...driftResult,
+ driftMetrics,
+ };
+ } else {
+ logger.info(`No cluster drift detected for user ${payload.userId}`);
+ result = {
+ driftDetected: false,
+ clustersProcessed: 0,
+ newClustersCreated: 0,
+ splitClusters: [],
+ driftMetrics,
+ };
+ }
+ break;
+
+ case "auto":
+ default:
+ result = await clusteringService.performClustering(
+ payload.userId,
+ payload.forceComplete,
+ );
+ logger.info(`Auto clustering completed for user ${payload.userId}:`, {
+ clustersCreated: result.clustersCreated,
+ statementsProcessed: result.statementsProcessed,
+ approach: result.approach,
+ });
+ break;
+ }
+
+ return {
+ success: true,
+ data: result,
+ };
+ } catch (error) {
+ logger.error(`${payload.mode} clustering failed for user ${payload.userId}:`, {
+ error,
+ });
+ throw error;
+ }
+ },
+});
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index cf85652..ec16e13 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -75,6 +75,7 @@
"@tiptap/starter-kit": "2.11.9",
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
"@trigger.dev/sdk": "^4.0.0-v4-beta.22",
+ "@types/react-calendar-heatmap": "^1.9.0",
"ai": "4.3.14",
"axios": "^1.10.0",
"bullmq": "^5.53.2",
@@ -111,6 +112,7 @@
"ollama-ai-provider": "1.2.0",
"posthog-js": "^1.116.6",
"react": "^18.2.0",
+ "react-calendar-heatmap": "^1.10.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^1.0.9",
"react-virtualized": "^9.22.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5022504..dd9fe51 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -463,6 +463,9 @@ importers:
'@trigger.dev/sdk':
specifier: ^4.0.0-v4-beta.22
version: 4.0.0-v4-beta.22(ai@4.3.14(react@18.3.1)(zod@3.23.8))(zod@3.23.8)
+ '@types/react-calendar-heatmap':
+ specifier: ^1.9.0
+ version: 1.9.0
ai:
specifier: 4.3.14
version: 4.3.14(react@18.3.1)(zod@3.23.8)
@@ -571,6 +574,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.3.1
+ react-calendar-heatmap:
+ specifier: ^1.10.0
+ version: 1.10.0(react@18.3.1)
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
@@ -4971,6 +4977,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+ '@types/react-calendar-heatmap@1.9.0':
+ resolution: {integrity: sha512-BH8M/nsXoLGa3hxWbrq3guPwlK0cV+w1i4c/ktrTxTzN5fBths6WbeUZ4dK0+tE76qiGoVSo9Tse8WVVuMIV+w==}
+
'@types/react-dom@18.2.18':
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
@@ -8272,6 +8281,9 @@ packages:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
+ memoize-one@5.2.1:
+ resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
+
memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'}
@@ -9655,6 +9667,11 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
+ react-calendar-heatmap@1.10.0:
+ resolution: {integrity: sha512-e5vcrzMWzKIF710egr1FpjWyuDEFeZm39nvV25muc8Wtqqi8iDOfqREELeQ9Wouqf9hhj939gq0i+iAxo7KdSw==}
+ peerDependencies:
+ react: '>=0.14.0'
+
react-css-styled@1.1.9:
resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
@@ -15906,6 +15923,10 @@ snapshots:
'@types/range-parser@1.2.7': {}
+ '@types/react-calendar-heatmap@1.9.0':
+ dependencies:
+ '@types/react': 18.2.69
+
'@types/react-dom@18.2.18':
dependencies:
'@types/react': 18.2.69
@@ -19898,6 +19919,8 @@ snapshots:
media-typer@1.1.0: {}
+ memoize-one@5.2.1: {}
+
memorystream@0.3.1: {}
meow@12.1.1: {}
@@ -21488,6 +21511,12 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
+ react-calendar-heatmap@1.10.0(react@18.3.1):
+ dependencies:
+ memoize-one: 5.2.1
+ prop-types: 15.8.1
+ react: 18.3.1
+
react-css-styled@1.1.9:
dependencies:
css-styled: 1.0.8