diff --git a/apps/api/src/clickhouse.ts b/apps/api/src/clickhouse.ts index c66946f8..e1a2f533 100644 --- a/apps/api/src/clickhouse.ts +++ b/apps/api/src/clickhouse.ts @@ -161,6 +161,14 @@ export function buildAbsoluteDateFilter( return `toDate(${column}) >= toDate({${startParamName}:String}) AND toDate(${column}) <= toDate({${endParamName}:String})`; } +export function buildInclusiveDateRangeFilter( + startParamName: string, + endParamName: string, + column = "session_date", +): string { + return buildAbsoluteDateFilter(startParamName, endParamName, column); +} + export async function queryClickhouse( statement: ClickHouseStatement, ): Promise { diff --git a/apps/api/src/handlers/analytics/overview.ts b/apps/api/src/handlers/analytics/overview.ts index f4bec966..3f62c1f5 100644 --- a/apps/api/src/handlers/analytics/overview.ts +++ b/apps/api/src/handlers/analytics/overview.ts @@ -3,9 +3,12 @@ import { getModelTokensTrend, getOverviewInsights, getOverviewKPIs, + getRepositoriesDailyTrend, getSuccessRateMetrics, getTeamSummaryWithComparison, getUsageTrendDetailed, + getUsersDailyTrend, + getUsersTokenUsage, } from "../../services/overview.service.js"; const kpis = os.analytics.overview.kpis @@ -38,6 +41,36 @@ const modelTokensTrend = os.analytics.overview.modelTokensTrend ); }); +const usersTokenUsage = os.analytics.overview.usersTokenUsage + .use(orgMiddleware) + .handler(async ({ input, context }) => { + return getUsersTokenUsage( + context.organizationId, + input.startDate, + input.endDate, + ); + }); + +const usersDailyTrend = os.analytics.overview.usersDailyTrend + .use(orgMiddleware) + .handler(async ({ input, context }) => { + return getUsersDailyTrend( + context.organizationId, + input.startDate, + input.endDate, + ); + }); + +const repositoriesDailyTrend = os.analytics.overview.repositoriesDailyTrend + .use(orgMiddleware) + .handler(async ({ input, context }) => { + return getRepositoriesDailyTrend( + context.organizationId, + input.startDate, + input.endDate, + ); + }); + const insights = os.analytics.overview.insights .use(orgMiddleware) .handler(async ({ input, context }) => { @@ -72,6 +105,9 @@ export const overviewRouter = os.analytics.overview.router({ kpis, usageTrend, modelTokensTrend, + usersTokenUsage, + usersDailyTrend, + repositoriesDailyTrend, insights, teamSummaryComparison, successRate, diff --git a/apps/api/src/handlers/analytics/roi.ts b/apps/api/src/handlers/analytics/roi.ts index d47d6d69..65d6d6a9 100644 --- a/apps/api/src/handlers/analytics/roi.ts +++ b/apps/api/src/handlers/analytics/roi.ts @@ -2,10 +2,20 @@ import { orgMiddleware, os } from "../../middleware.js"; import { getDeveloperCostBreakdown, getProjectCostBreakdown, + getROIDashboard, getROIMetrics, getROITrends, } from "../../services/roi.service.js"; +const dashboard = os.analytics.roi.dashboard + .use(orgMiddleware) + .handler(async ({ input, context }) => { + return getROIDashboard(context.organizationId, { + start_date: input.startDate, + end_date: input.endDate, + }); + }); + const metrics = os.analytics.roi.metrics .use(orgMiddleware) .handler(async ({ input, context }) => { @@ -31,6 +41,7 @@ const breakdownProjects = os.analytics.roi.breakdownProjects }); export const roiRouter = os.analytics.roi.router({ + dashboard, metrics, trends, breakdownDevelopers, diff --git a/apps/api/src/services/overview.service.ts b/apps/api/src/services/overview.service.ts index 26aa85c1..00a5717e 100644 --- a/apps/api/src/services/overview.service.ts +++ b/apps/api/src/services/overview.service.ts @@ -1,7 +1,10 @@ import type { ModelTokensTrendData, OverviewKPIs, + RepositoryDailyTrendData, UsageTrendData, + UserDailyTrendData, + UserTokenUsageData, } from "@rudel/api-routes"; import { user } from "@rudel/sql-schema"; import { eq } from "drizzle-orm"; @@ -11,6 +14,7 @@ import { queryClickhouse, } from "../clickhouse.js"; import { db } from "../db.js"; +import { buildEstimatedCostSql } from "./pricing.service.js"; export interface Insight { type: "trend" | "performer" | "alert" | "info"; @@ -19,6 +23,14 @@ export interface Insight { link: string; } +const PER_SESSION_COST_SQL = buildEstimatedCostSql({ + modelExpr: "model_used", + inputExpr: "ifNull(input_tokens, 0)", + outputExpr: "ifNull(output_tokens, 0)", + cacheReadInputExpr: "ifNull(cache_read_input_tokens, 0)", + cacheCreationInputExpr: "ifNull(cache_creation_input_tokens, 0)", +}); + /** * Get overview KPI counts: distinct users, sessions, projects, subagents, skills, slash commands */ @@ -155,6 +167,193 @@ export async function getModelTokensTrend( }); } +export async function getUsersTokenUsage( + orgId: string, + startDate: string, + endDate: string, +): Promise { + const dateFilter = buildAbsoluteDateFilter("startDate", "endDate"); + const rows = await queryClickhouse<{ + models_used: string[]; + repositories_touched: string[]; + user_id: string; + total_commits: number; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost: number; + total_sessions: number; + total_duration_min: number; + success_rate: number; + distinct_skills: number; + distinct_slash_commands: number; + }>({ + query: ` + SELECT + user_id, + arrayFilter( + x -> x != '', + topK(3)(if(model_used != '' AND model_used != 'unknown', model_used, '')) + ) as models_used, + arraySort( + arrayDistinct( + arrayFilter( + x -> x != '', + groupArray( + if( + git_remote != '', + replaceRegexpOne(arrayElement(splitByChar('/', git_remote), -1), '\\\\.git$', ''), + if( + package_name != '', + package_name, + arrayElement(splitByChar('/', replaceAll(project_path, '\\\\', '/')), -1) + ) + ) + ) + ) + ) + ) as repositories_touched, + sum(has_commit) as total_commits, + sum(ifNull(total_tokens, 0)) as total_tokens, + sum(ifNull(input_tokens, 0)) as input_tokens, + sum(ifNull(output_tokens, 0)) as output_tokens, + round(sum(${PER_SESSION_COST_SQL}), 4) as cost, + count() as total_sessions, + round(sum(actual_duration_min), 2) as total_duration_min, + round(avg(success_score), 2) as success_rate, + length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(skills))))) as distinct_skills, + length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(slash_commands))))) as distinct_slash_commands + FROM rudel.session_analytics + WHERE ${dateFilter} + AND organization_id = {orgId:String} + AND user_id != '' + GROUP BY user_id + ORDER BY total_tokens DESC + `, + query_params: { + startDate, + endDate, + orgId, + }, + }); + + if (rows.length === 0) { + return []; + } + + return rows.map((row) => ({ + models_used: row.models_used ?? [], + repositories_touched: row.repositories_touched ?? [], + user_id: row.user_id, + user_label: row.user_id, + total_commits: Number(row.total_commits), + total_tokens: Number(row.total_tokens), + input_tokens: Number(row.input_tokens), + output_tokens: Number(row.output_tokens), + cost: Number(row.cost), + total_sessions: Number(row.total_sessions), + total_duration_min: Number(row.total_duration_min), + success_rate: Number(row.success_rate), + distinct_skills: Number(row.distinct_skills), + distinct_slash_commands: Number(row.distinct_slash_commands), + })); +} + +export async function getUsersDailyTrend( + orgId: string, + startDate: string, + endDate: string, +): Promise { + const dateFilter = buildAbsoluteDateFilter("startDate", "endDate"); + + return queryClickhouse({ + query: ` + SELECT + toString(toDate(session_date)) as date, + user_id, + count() as sessions, + sum(has_commit) as total_commits, + round(sum(actual_duration_min) / 60, 2) as total_hours, + sum(ifNull(total_tokens, 0)) as total_tokens, + sum(ifNull(input_tokens, 0)) as input_tokens, + sum(ifNull(output_tokens, 0)) as output_tokens, + round(avg(success_score), 2) as avg_success_rate, + length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(skills))))) as distinct_skills, + length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(slash_commands))))) as distinct_slash_commands, + arrayFilter( + x -> x != '', + arrayDistinct(groupArray(if(model_used != '' AND model_used != 'unknown', model_used, ''))) + ) as models_used, + arraySort( + arrayDistinct( + arrayFilter( + x -> x != '', + groupArray( + if( + git_remote != '', + replaceRegexpOne(arrayElement(splitByChar('/', git_remote), -1), '\\\\.git$', ''), + if( + package_name != '', + package_name, + arrayElement(splitByChar('/', replaceAll(project_path, '\\\\', '/')), -1) + ) + ) + ) + ) + ) + ) as repositories_touched + FROM rudel.session_analytics + WHERE ${dateFilter} + AND organization_id = {orgId:String} + AND user_id != '' + GROUP BY date, user_id + ORDER BY date ASC, user_id ASC + `, + query_params: { + startDate, + endDate, + orgId, + }, + }); +} + +export async function getRepositoriesDailyTrend( + orgId: string, + startDate: string, + endDate: string, +): Promise { + const dateFilter = buildAbsoluteDateFilter("startDate", "endDate"); + + return queryClickhouse({ + query: ` + SELECT + toString(toDate(session_date)) as date, + if( + git_remote != '', + replaceRegexpOne(arrayElement(splitByChar('/', git_remote), -1), '\\\\.git$', ''), + if( + package_name != '', + package_name, + arrayElement(splitByChar('/', replaceAll(project_path, '\\\\', '/')), -1) + ) + ) as repository, + count() as sessions, + sum(has_commit) as total_commits + FROM rudel.session_analytics + WHERE ${dateFilter} + AND organization_id = {orgId:String} + GROUP BY date, repository + HAVING repository != '' + ORDER BY date ASC, repository ASC + `, + query_params: { + startDate, + endDate, + orgId, + }, + }); +} + /** * Get detailed usage trend data aggregated by day */ diff --git a/apps/api/src/services/pricing.service.ts b/apps/api/src/services/pricing.service.ts new file mode 100644 index 00000000..a73bccc1 --- /dev/null +++ b/apps/api/src/services/pricing.service.ts @@ -0,0 +1,9 @@ +export { + buildEstimatedCostSql, + calculateEstimatedCost, + ESTIMATED_PRICING_MODE, + FALLBACK_MODEL_PRICING, + getModelPricingCatalog, + MODEL_PRICING_CATALOG_VERSION, + resolveModelPricing, +} from "@rudel/api-routes"; diff --git a/apps/api/src/services/roi.service.ts b/apps/api/src/services/roi.service.ts index 7d0c2088..332504df 100644 --- a/apps/api/src/services/roi.service.ts +++ b/apps/api/src/services/roi.service.ts @@ -1,10 +1,21 @@ import type { DeveloperCostBreakdown, ProjectCostBreakdown, + ROIDashboard, ROIMetrics, ROITrend, } from "@rudel/api-routes"; -import { buildDateFilter, queryClickhouse } from "../clickhouse.js"; +import { + buildDateFilter, + buildInclusiveDateRangeFilter, + queryClickhouse, +} from "../clickhouse.js"; +import { + buildEstimatedCostSql, + ESTIMATED_PRICING_MODE, + FALLBACK_MODEL_PRICING, + getModelPricingCatalog, +} from "./pricing.service.js"; // Pricing constants based on Claude Sonnet 4 rates, used as a default approximation // across all models. TODO: implement per-model pricing using the model_used column. @@ -14,6 +25,13 @@ import { buildDateFilter, queryClickhouse } from "../clickhouse.js"; const INPUT_PRICE_PER_MILLION = 3.0; const OUTPUT_PRICE_PER_MILLION = 15.0; const DEFAULT_DEV_HOURLY_RATE = 100; +const PER_SESSION_COST_SQL = buildEstimatedCostSql({ + modelExpr: "model_used", + inputExpr: "ifNull(input_tokens, 0)", + outputExpr: "ifNull(output_tokens, 0)", + cacheReadInputExpr: "ifNull(cache_read_input_tokens, 0)", + cacheCreationInputExpr: "ifNull(cache_creation_input_tokens, 0)", +}); // ROI calculation constants const CODE_PERCENTAGE = 0.65; // 65% of output tokens are actual code @@ -75,6 +93,128 @@ interface ProjectBreakdownQueryResult { avg_success_score: number; } +interface RangeSnapshotRow { + total_sessions: number; + total_input_tokens: number; + total_output_tokens: number; + total_tokens: number; + total_cost: number; + total_hours: number; + avg_success_score: number; + active_developers: number; + total_commits: number; +} + +interface ROIDashboardTrendQueryRow { + bucket_start: string; + total_sessions: number; + total_input_tokens: number; + total_output_tokens: number; + total_tokens: number; + total_cost: number; + total_commits: number; +} + +type TrendInterval = "day" | "week" | "month"; + +interface DerivedROISnapshot { + total_cost: number; + dollar_value_saved: number; + roi_percentage: number; + dev_hours_saved: number; + commits_per_dollar: number; + sessions_per_dollar: number; + total_sessions: number; + total_commits: number; + active_developers: number; + avg_success_score: number; +} + +function roundTo(value: number, digits = 2) { + return Number(value.toFixed(digits)); +} + +function calculateChangePct(current: number, previous: number) { + if (!Number.isFinite(previous) || previous === 0) { + return 0; + } + + return roundTo(((current - previous) / previous) * 100); +} + +function shiftIsoDate(isoDate: string, days: number) { + const date = new Date(`${isoDate}T00:00:00.000Z`); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +function getInclusiveDateSpanDays(startDate: string, endDate: string) { + const start = new Date(`${startDate}T00:00:00.000Z`).getTime(); + const end = new Date(`${endDate}T00:00:00.000Z`).getTime(); + return Math.floor((end - start) / 86_400_000) + 1; +} + +function getTrendIntervalForRange(dayCount: number): TrendInterval { + if (dayCount <= 31) { + return "day"; + } + + if (dayCount <= 120) { + return "week"; + } + + return "month"; +} + +function formatTrendBucketLabel(bucketStart: string, interval: TrendInterval) { + const date = new Date(`${bucketStart}T00:00:00.000Z`); + + if (interval === "day" || interval === "week") { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }).format(date); + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + year: "numeric", + }).format(date); +} + +function deriveROISnapshot(row?: RangeSnapshotRow): DerivedROISnapshot { + const totalSessions = Number(row?.total_sessions) || 0; + const totalOutputTokens = Number(row?.total_output_tokens) || 0; + const totalCost = Number(row?.total_cost) || 0; + const totalCommits = Number(row?.total_commits) || 0; + const activeDevelopers = Number(row?.active_developers) || 0; + const avgSuccessScore = roundTo(Number(row?.avg_success_score) || 0); + const estimatedLocGenerated = + (totalOutputTokens * CODE_PERCENTAGE) / TOKENS_PER_LOC; + const devHoursSaved = roundTo(estimatedLocGenerated / LOC_PER_HOUR); + const estimatedValueCreated = devHoursSaved * DEFAULT_DEV_HOURLY_RATE; + const dollarValueSaved = roundTo(estimatedValueCreated - totalCost); + const roiPercentage = + totalCost > 0 ? roundTo((dollarValueSaved / totalCost) * 100) : 0; + const commitsPerDollar = + totalCost > 0 ? roundTo(totalCommits / totalCost) : 0; + const sessionsPerDollar = + totalCost > 0 ? roundTo(totalSessions / totalCost) : 0; + + return { + total_cost: totalCost, + dollar_value_saved: dollarValueSaved, + roi_percentage: roiPercentage, + dev_hours_saved: devHoursSaved, + commits_per_dollar: commitsPerDollar, + sessions_per_dollar: sessionsPerDollar, + total_sessions: totalSessions, + total_commits: totalCommits, + active_developers: activeDevelopers, + avg_success_score: avgSuccessScore, + }; +} + /** * Get comprehensive ROI metrics with period-over-period comparison */ @@ -428,3 +568,271 @@ export async function getProjectCostBreakdown( }; }); } + +async function getRangeSnapshot( + orgId: string, + startDate: string, + endDate: string, +): Promise { + const query = ` + SELECT + COUNT(*) as total_sessions, + SUM(input_tokens) as total_input_tokens, + SUM(output_tokens) as total_output_tokens, + SUM(total_tokens) as total_tokens, + round(SUM(${PER_SESSION_COST_SQL}), 4) as total_cost, + SUM(actual_duration_min) / 60.0 as total_hours, + AVG(success_score) as avg_success_score, + COUNT(DISTINCT user_id) as active_developers, + SUM(has_commit) as total_commits + FROM rudel.session_analytics + WHERE ${buildInclusiveDateRangeFilter("startDate", "endDate")} + AND organization_id = {orgId:String} + `; + + const result = await queryClickhouse({ + query, + query_params: { + startDate, + endDate, + orgId, + }, + }); + + return result[0]; +} + +async function getDeveloperCostBreakdownForRange( + orgId: string, + startDate: string, + endDate: string, +): Promise { + const query = ` + SELECT + user_id, + COUNT(*) as total_sessions, + SUM(total_tokens) as total_tokens, + AVG(success_score) as avg_success_score, + round(SUM(${PER_SESSION_COST_SQL}), 4) as total_cost + FROM rudel.session_analytics + WHERE ${buildInclusiveDateRangeFilter("startDate", "endDate")} + AND organization_id = {orgId:String} + GROUP BY user_id + ORDER BY total_cost DESC, total_tokens DESC, user_id ASC + `; + + const result = await queryClickhouse({ + query, + query_params: { + startDate, + endDate, + orgId, + }, + }); + + const grandTotalCost = result.reduce( + (sum, row) => sum + (Number(row.total_cost) || 0), + 0, + ); + + return result.map((row) => { + const cost = Number(row.total_cost) || 0; + + return { + user_id: row.user_id, + sessions: Number(row.total_sessions) || 0, + total_tokens: Number(row.total_tokens) || 0, + cost, + cost_percentage: + grandTotalCost > 0 ? roundTo((cost / grandTotalCost) * 100) : 0, + avg_success_score: roundTo(Number(row.avg_success_score) || 0), + }; + }); +} + +async function getProjectCostBreakdownForRange( + orgId: string, + startDate: string, + endDate: string, +): Promise { + const query = ` + SELECT + if(git_remote != '', git_remote, if(package_name != '', package_name, arrayElement(splitByChar('/', project_path), -1))) as project_path, + COUNT(*) as total_sessions, + SUM(total_tokens) as total_tokens, + AVG(success_score) as avg_success_score, + round(SUM(${PER_SESSION_COST_SQL}), 4) as total_cost + FROM rudel.session_analytics + WHERE ${buildInclusiveDateRangeFilter("startDate", "endDate")} + AND organization_id = {orgId:String} + AND project_path != '' + GROUP BY project_path + ORDER BY total_cost DESC, total_tokens DESC, project_path ASC + `; + + const result = await queryClickhouse({ + query, + query_params: { + startDate, + endDate, + orgId, + }, + }); + + const grandTotalCost = result.reduce( + (sum, row) => sum + (Number(row.total_cost) || 0), + 0, + ); + + return result.map((row) => { + const cost = Number(row.total_cost) || 0; + + return { + project_path: row.project_path, + sessions: Number(row.total_sessions) || 0, + total_tokens: Number(row.total_tokens) || 0, + cost, + cost_percentage: + grandTotalCost > 0 ? roundTo((cost / grandTotalCost) * 100) : 0, + avg_success_score: roundTo(Number(row.avg_success_score) || 0), + }; + }); +} + +export async function getROIDashboard( + orgId: string, + params: { + start_date: string; + end_date: string; + }, +): Promise { + const { end_date, start_date } = params; + const spanDays = getInclusiveDateSpanDays(start_date, end_date); + const comparison_end_date = shiftIsoDate(start_date, -1); + const comparison_start_date = shiftIsoDate( + comparison_end_date, + -(spanDays - 1), + ); + const trendInterval = getTrendIntervalForRange(spanDays); + const bucketExpr = + trendInterval === "day" + ? "toDate(session_date)" + : trendInterval === "week" + ? "toMonday(session_date)" + : "toStartOfMonth(session_date)"; + + const trendQuery = ` + SELECT + toString(${bucketExpr}) as bucket_start, + COUNT(*) as total_sessions, + SUM(input_tokens) as total_input_tokens, + SUM(output_tokens) as total_output_tokens, + SUM(total_tokens) as total_tokens, + round(SUM(${PER_SESSION_COST_SQL}), 4) as total_cost, + SUM(has_commit) as total_commits + FROM rudel.session_analytics + WHERE ${buildInclusiveDateRangeFilter("startDate", "endDate")} + AND organization_id = {orgId:String} + GROUP BY bucket_start + ORDER BY bucket_start ASC + `; + + const [ + currentSnapshotRow, + previousSnapshotRow, + trendRows, + developerBreakdown, + projectBreakdown, + ] = await Promise.all([ + getRangeSnapshot(orgId, start_date, end_date), + getRangeSnapshot(orgId, comparison_start_date, comparison_end_date), + queryClickhouse({ + query: trendQuery, + query_params: { + startDate: start_date, + endDate: end_date, + orgId, + }, + }), + getDeveloperCostBreakdownForRange(orgId, start_date, end_date), + getProjectCostBreakdownForRange(orgId, start_date, end_date), + ]); + + const current = deriveROISnapshot(currentSnapshotRow); + const previous = deriveROISnapshot(previousSnapshotRow); + + return { + start_date, + end_date, + comparison_start_date, + comparison_end_date, + summary: { + total_cost: current.total_cost, + total_cost_change_pct: calculateChangePct( + current.total_cost, + previous.total_cost, + ), + dollar_value_saved: current.dollar_value_saved, + dollar_value_saved_change_pct: calculateChangePct( + current.dollar_value_saved, + previous.dollar_value_saved, + ), + roi_percentage: current.roi_percentage, + roi_percentage_change_pct: calculateChangePct( + current.roi_percentage, + previous.roi_percentage, + ), + dev_hours_saved: current.dev_hours_saved, + dev_hours_saved_change_pct: calculateChangePct( + current.dev_hours_saved, + previous.dev_hours_saved, + ), + commits_per_dollar: current.commits_per_dollar, + sessions_per_dollar: current.sessions_per_dollar, + total_sessions: current.total_sessions, + total_commits: current.total_commits, + active_developers: current.active_developers, + avg_success_score: current.avg_success_score, + }, + assumptions: { + pricing_mode: ESTIMATED_PRICING_MODE, + priced_model_entries: getModelPricingCatalog().length, + fallback_input_price_per_million: FALLBACK_MODEL_PRICING.inputPerMillion, + fallback_output_price_per_million: + FALLBACK_MODEL_PRICING.outputPerMillion, + code_percentage: CODE_PERCENTAGE, + tokens_per_loc: TOKENS_PER_LOC, + loc_per_hour: LOC_PER_HOUR, + developer_hourly_rate: DEFAULT_DEV_HOURLY_RATE, + }, + trend_interval: trendInterval, + trend: trendRows.map((row) => { + const snapshot = deriveROISnapshot({ + total_sessions: Number(row.total_sessions) || 0, + total_input_tokens: Number(row.total_input_tokens) || 0, + total_output_tokens: Number(row.total_output_tokens) || 0, + total_tokens: Number(row.total_tokens) || 0, + total_cost: Number(row.total_cost) || 0, + total_hours: 0, + avg_success_score: 0, + active_developers: 0, + total_commits: Number(row.total_commits) || 0, + }); + + return { + bucket_start: row.bucket_start, + bucket_label: formatTrendBucketLabel(row.bucket_start, trendInterval), + total_cost: snapshot.total_cost, + dollar_value_saved: snapshot.dollar_value_saved, + roi_percentage: snapshot.roi_percentage, + dev_hours_saved: snapshot.dev_hours_saved, + commits_per_dollar: snapshot.commits_per_dollar, + sessions_per_dollar: snapshot.sessions_per_dollar, + total_sessions: Number(row.total_sessions) || 0, + total_commits: Number(row.total_commits) || 0, + }; + }), + developer_breakdown: developerBreakdown, + project_breakdown: projectBreakdown, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 56eced50..0a7d98ae 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,9 +7,13 @@ "dev": "vite", "build": "tsc -b && vite build", "check-types": "tsc -b", + "test": "bun test", "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.3.0", + "@fontsource/geist-mono": "^5.2.7", + "@fontsource/nunito": "^5.2.7", "@orpc/client": "latest", "@orpc/contract": "latest", "@orpc/tanstack-query": "latest", @@ -26,6 +30,7 @@ "posthog-js": "^1.292.0", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", @@ -44,6 +49,7 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.1.1", + "bun-types": "latest", "shadcn": "^3.8.5", "tailwindcss": "^4.1.0", "tw-animate-css": "^1.4.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7cbc042f..0a057b77 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,280 +1,68 @@ -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; -import { LoginForm } from "./components/auth/login-form"; -import { ResetPasswordForm } from "./components/auth/reset-password-form"; -import { SignupForm } from "./components/auth/signup-form"; -import { Button } from "./components/ui/button"; -import { useAnalyticsTracking } from "./hooks/useDashboardAnalytics"; -import { DashboardLayout } from "./layouts/DashboardLayout"; -import { authClient } from "./lib/auth-client"; +import { useLocation } from "react-router-dom"; +import { AppLoadingScreen } from "@/app/bootstrap/AppLoadingScreen"; +import { ProductAnalyticsSessionSync } from "@/features/analytics/tracking/ProductAnalyticsSessionSync"; +import { AuthenticatedApp } from "@/features/auth/AuthenticatedApp"; import { - identifyProductAnalyticsUser, - resetProductAnalytics, -} from "./lib/product-analytics"; -import { AcceptInvitationPage } from "./pages/AcceptInvitationPage"; -import { AdminPage } from "./pages/dashboard/AdminPage"; -import { CreateOrgPage } from "./pages/dashboard/CreateOrgPage"; -import { DeveloperDetailPage } from "./pages/dashboard/DeveloperDetailPage"; -import { DevelopersListPage } from "./pages/dashboard/DevelopersListPage"; -import { ErrorsPage } from "./pages/dashboard/ErrorsPage"; -import { InvitationsPage } from "./pages/dashboard/InvitationsPage"; -import { LearningsPage } from "./pages/dashboard/LearningsPage"; -import { OrganizationPage } from "./pages/dashboard/OrganizationPage"; -import { OverviewPage } from "./pages/dashboard/OverviewPage"; -import { ProfilePage } from "./pages/dashboard/ProfilePage"; -import { ProjectDetailPage } from "./pages/dashboard/ProjectDetailPage"; -import { ProjectsListPage } from "./pages/dashboard/ProjectsListPage"; -import { ROIPage } from "./pages/dashboard/ROIPage"; -import { SessionDetailPage } from "./pages/dashboard/SessionDetailPage"; -import { SessionsListPage } from "./pages/dashboard/SessionsListPage"; - -type Page = "login" | "signup"; - -function isResetPasswordPath() { - return window.location.pathname === "/reset-password"; -} - -function getDeviceUserCode(): string | null { - const params = new URLSearchParams(window.location.search); - return params.get("user_code"); -} - -function getValidRedirect(): string | null { - const params = new URLSearchParams(window.location.search); - const redirect = params.get("redirect"); - if (!redirect) return null; - if (!redirect.startsWith("/") || redirect.startsWith("//")) return null; - return redirect; -} + getDeviceUserCode, + getValidRedirect, + isResetPasswordPath, +} from "@/features/auth/auth-route-utils"; +import { DeviceAuthorizationApp } from "@/features/auth/DeviceAuthorizationApp"; +import { GuestApp } from "@/features/auth/GuestApp"; +import { ResetPasswordApp } from "@/features/auth/ResetPasswordApp"; +import { authClient } from "./lib/auth-client"; function App() { + const location = useLocation(); const { data: session, isPending } = authClient.useSession(); - const { trackAuthenticationAction } = useAnalyticsTracking({ - pageName: "device_login", - }); - const [page, setPage] = useState("login"); - const [deviceProcessing, setDeviceProcessing] = useState(false); - const [deviceApproved, setDeviceApproved] = useState(false); - const [deviceDenied, setDeviceDenied] = useState(false); - const [deviceError, setDeviceError] = useState(null); - const deviceUserCode = getDeviceUserCode(); - const { resolvedTheme } = useTheme(); - const logoSrc = - resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg"; - - useEffect(() => { - const userId = - session?.user && - "id" in session.user && - typeof session.user.id === "string" - ? session.user.id - : null; - const email = - session?.user && - "email" in session.user && - typeof session.user.email === "string" - ? session.user.email - : undefined; - const name = - session?.user && - "name" in session.user && - typeof session.user.name === "string" - ? session.user.name - : undefined; - - if (userId) { - identifyProductAnalyticsUser(userId, { - email, - name, - }); - return; - } - - resetProductAnalytics(); - }, [session]); - - async function submitDeviceDecision(action: "approve" | "deny") { - if (!deviceUserCode || deviceProcessing) return; - const userId = - session?.user && - "id" in session.user && - typeof session.user.id === "string" - ? session.user.id - : undefined; - trackAuthenticationAction({ - actionName: - action === "approve" ? "approve_device_login" : "deny_device_login", - sourceComponent: "device_login", - authMethod: "device_code", - targetId: deviceUserCode, - userId, - }); - setDeviceProcessing(true); - setDeviceError(null); - try { - const response = await fetch(`/api/auth/device/${action}`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userCode: deviceUserCode }), - }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as { - error_description?: string; - message?: string; - } | null; - throw new Error( - body?.error_description ?? - body?.message ?? - `Failed to ${action} CLI device login`, - ); - } - if (action === "approve") { - setDeviceApproved(true); - } else { - setDeviceDenied(true); - } - } catch (err) { - setDeviceError( - err instanceof Error ? err.message : "Failed to process device login", - ); - } finally { - setDeviceProcessing(false); - } - } + const deviceUserCode = getDeviceUserCode(location.search); + const rootRedirectTarget = getValidRedirect(location.search); if (isPending) { return ( -
-

Loading...

-
+ <> + + + ); } if (deviceUserCode) { - if (deviceProcessing) { - return ( -
-

Processing CLI login...

-
- ); - } - - if (deviceApproved) { - return ( -
-

CLI login approved

-

- Return to your terminal to continue. -

-
- ); - } - - if (deviceDenied) { - return ( -
-

CLI login denied

-

- This authorization request was not approved. -

-
- ); - } - - if (!session) { - return ( -
- Rudel - {page === "login" ? ( - setPage("signup")} /> - ) : ( - setPage("login")} /> - )} -
- ); - } - return ( -
-

Authorize CLI login

-

- User code: {deviceUserCode} -

- {deviceError ? ( -

{deviceError}

- ) : ( -

- Approve this request only if it was initiated by you from the CLI. -

- )} -
- - -
-
+ <> + + + ); } - if (!session && isResetPasswordPath()) { + if (session) { return ( -
- Rudel - (window.location.href = "/")} /> -
+ <> + + + ); } - if (!session) { + if (isResetPasswordPath(location.pathname)) { return ( -
- Rudel - {page === "login" ? ( - setPage("signup")} /> - ) : ( - setPage("login")} /> - )} -
+ <> + + + ); } return ( - - } - /> - } - /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - + <> + + + ); } diff --git a/apps/web/src/app/AppRouter.tsx b/apps/web/src/app/AppRouter.tsx new file mode 100644 index 00000000..e6a9c11a --- /dev/null +++ b/apps/web/src/app/AppRouter.tsx @@ -0,0 +1,55 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { DashboardPage } from "@/features/dashboard/DashboardPage"; +import { AppShellLayout } from "@/features/shell/AppShellLayout"; +import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage"; +import { AdminPage } from "@/pages/dashboard/AdminPage"; +import { CreateOrgPage } from "@/pages/dashboard/CreateOrgPage"; +import { DeveloperDetailPage } from "@/pages/dashboard/DeveloperDetailPage"; +import { DevelopersListPage } from "@/pages/dashboard/DevelopersListPage"; +import { ErrorsPage } from "@/pages/dashboard/ErrorsPage"; +import { InvitationsPage } from "@/pages/dashboard/InvitationsPage"; +import { LearningsPage } from "@/pages/dashboard/LearningsPage"; +import { OrganizationPage } from "@/pages/dashboard/OrganizationPage"; +import { ProfilePage } from "@/pages/dashboard/ProfilePage"; +import { ProjectDetailPage } from "@/pages/dashboard/ProjectDetailPage"; +import { ProjectsListPage } from "@/pages/dashboard/ProjectsListPage"; +import { ROIPage } from "@/pages/dashboard/ROIPage"; +import { SessionDetailPage } from "@/pages/dashboard/SessionDetailPage"; +import { SessionsListPage } from "@/pages/dashboard/SessionsListPage"; + +export function AppRouter({ + rootRedirectTarget, +}: { + rootRedirectTarget: string | null; +}) { + return ( + + } + /> + } + /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} diff --git a/apps/web/src/app/bootstrap/AppLoadingScreen.tsx b/apps/web/src/app/bootstrap/AppLoadingScreen.tsx new file mode 100644 index 00000000..cc770cf9 --- /dev/null +++ b/apps/web/src/app/bootstrap/AppLoadingScreen.tsx @@ -0,0 +1,11 @@ +export function AppLoadingScreen({ + message = "Loading...", +}: { + message?: string; +}) { + return ( +
+

{message}

+
+ ); +} diff --git a/apps/web/src/app/preset-extensions.css b/apps/web/src/app/preset-extensions.css new file mode 100644 index 00000000..bdfe2b0d --- /dev/null +++ b/apps/web/src/app/preset-extensions.css @@ -0,0 +1,102 @@ +@import "@fontsource/geist-mono/400.css"; +@import "@fontsource/nunito/800.css"; + +@theme inline { + --color-surface: var(--surface); + --color-heading: var(--heading); + --color-subheading: var(--subheading); + --color-hover: var(--hover); + --color-accent-hover: var(--accent-hover); + --color-accent-light: var(--accent-light); + --color-accent-text: var(--accent-text); + --color-status-success-bg: var(--status-success-bg); + --color-status-success-border: var(--status-success-border); + --color-status-success-text: var(--status-success-text); + --color-status-success-icon: var(--status-success-icon); + --color-status-error-bg: var(--status-error-bg); + --color-status-error-border: var(--status-error-border); + --color-status-error-text: var(--status-error-text); + --color-status-error-icon: var(--status-error-icon); + --color-status-warning-bg: var(--status-warning-bg); + --color-status-warning-border: var(--status-warning-border); + --color-status-warning-text: var(--status-warning-text); + --color-status-warning-icon: var(--status-warning-icon); + --color-status-info-bg: var(--status-info-bg); + --color-status-info-border: var(--status-info-border); + --color-status-info-text: var(--status-info-text); + --color-status-info-icon: var(--status-info-icon); + --color-chart-tooltip-bg: var(--chart-tooltip-bg); + --color-chart-tooltip-border: var(--chart-tooltip-border); + --color-chart-grid: var(--chart-grid); + --color-chart-axis: var(--chart-axis); +} + +:root { + --app-font-heading: "Nunito", var(--font-sans); + --app-font-sans: var(--font-sans); + --surface: #f7f8f9; + --heading: oklch(0.145 0 0); + --subheading: oklch(0.556 0 0); + --hover: oklch(0.97 0 0); + --accent-hover: oklch(0.205 0 0); + --accent-light: oklch(0.97 0 0); + --accent-text: oklch(0.205 0 0); + --status-success-bg: #f0fdf4; + --status-success-border: #bbf7d0; + --status-success-text: #166534; + --status-success-icon: #16a34a; + --status-error-bg: oklch(0.975 0.014 17.38); + --status-error-border: oklch(0.884 0.062 18.334); + --status-error-text: oklch(0.444 0.177 26.899); + --status-error-icon: oklch(0.577 0.245 27.325); + --status-warning-bg: oklch(0.987 0.026 102.212); + --status-warning-border: oklch(0.905 0.129 101.54); + --status-warning-text: oklch(0.473 0.124 46.201); + --status-warning-icon: oklch(0.646 0.222 41.116); + --status-info-bg: oklch(0.97 0.014 254.604); + --status-info-border: oklch(0.882 0.059 254.128); + --status-info-text: oklch(0.488 0.243 264.376); + --status-info-icon: oklch(0.546 0.245 262.881); + --chart-tooltip-bg: oklch(1 0 0); + --chart-tooltip-border: oklch(0.922 0 0); + --chart-grid: oklch(0.97 0 0); + --chart-axis: oklch(0.556 0 0); +} + +.dark { + --surface: oklch(0.145 0 0); + --heading: oklch(0.985 0 0); + --subheading: oklch(0.708 0 0); + --hover: oklch(0.269 0 0); + --accent-hover: oklch(0.922 0 0); + --accent-light: oklch(0.269 0 0); + --accent-text: oklch(0.985 0 0); + --status-success-bg: oklch(0.266 0.065 152.934); + --status-success-border: oklch(0.393 0.095 152.535); + --status-success-text: oklch(0.793 0.118 152.498); + --status-success-icon: oklch(0.696 0.17 162.48); + --status-error-bg: oklch(0.258 0.092 26.042); + --status-error-border: oklch(0.396 0.141 25.723); + --status-error-text: oklch(0.808 0.114 19.571); + --status-error-icon: oklch(0.704 0.191 22.216); + --status-warning-bg: oklch(0.274 0.055 45.571); + --status-warning-border: oklch(0.414 0.112 45.904); + --status-warning-text: oklch(0.879 0.169 91.605); + --status-warning-icon: oklch(0.769 0.188 70.08); + --status-info-bg: oklch(0.282 0.091 267.935); + --status-info-border: oklch(0.379 0.146 265.522); + --status-info-text: oklch(0.785 0.115 274.713); + --status-info-icon: oklch(0.707 0.165 254.624); + --chart-tooltip-bg: oklch(0.205 0 0); + --chart-tooltip-border: oklch(1 0 0 / 10%); + --chart-grid: oklch(0.269 0 0); + --chart-axis: oklch(0.708 0 0); +} + +@layer base { + html, + body, + #root { + @apply h-full; + } +} diff --git a/apps/web/src/app/providers/AppProviders.tsx b/apps/web/src/app/providers/AppProviders.tsx new file mode 100644 index 00000000..bbd55696 --- /dev/null +++ b/apps/web/src/app/providers/AppProviders.tsx @@ -0,0 +1,15 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; +import { queryClient } from "@/lib/query-client"; +import { ThemeProvider } from "@/providers/ThemeProvider"; + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/ui/button.tsx b/apps/web/src/app/ui/button.tsx new file mode 100644 index 00000000..aa3d30f0 --- /dev/null +++ b/apps/web/src/app/ui/button.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-9", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/web/src/app/ui/calendar.tsx b/apps/web/src/app/ui/calendar.tsx new file mode 100644 index 00000000..4dab360b --- /dev/null +++ b/apps/web/src/app/ui/calendar.tsx @@ -0,0 +1,216 @@ +import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { + type DayButton, + DayPicker, + getDefaultClassNames, + type Locale, +} from "react-day-picker"; +import { Button, buttonVariants } from "@/app/ui/button"; +import { cn } from "@/lib/utils"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"]; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months, + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav, + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next, + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption, + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns, + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root, + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown, + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label, + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday, + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header, + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number, + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day, + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start, + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end, + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today, + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside, + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled, + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ); + } + + if (orientation === "right") { + return ( + + ); + } + + return ; + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + + ); + })} +
+ + + +
+
+ + date < supportedDateRange.start || + date > supportedDateRange.end + } + /> +
+ +
+
+ + +
+
+
+ + + + ); +} diff --git a/apps/web/src/features/analytics/date-range/date-presets.test.ts b/apps/web/src/features/analytics/date-range/date-presets.test.ts new file mode 100644 index 00000000..9fd4ce93 --- /dev/null +++ b/apps/web/src/features/analytics/date-range/date-presets.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { + formatDashboardDateRangeTriggerLabel, + getAnalyticsDatePresets, + parseIsoDateOnly, + resolveMatchingAnalyticsPreset, +} from "./date-presets"; + +describe("date-presets", () => { + test("parseIsoDateOnly accepts valid ISO calendar dates", () => { + const parsedDate = parseIsoDateOnly("2026-04-08"); + + expect(parsedDate).not.toBeNull(); + expect(parsedDate?.getFullYear()).toBe(2026); + expect(parsedDate?.getMonth()).toBe(3); + expect(parsedDate?.getDate()).toBe(8); + }); + + test("parseIsoDateOnly rejects impossible calendar dates", () => { + expect(parseIsoDateOnly("2026-02-31")).toBeNull(); + expect(parseIsoDateOnly("2026/04/08")).toBeNull(); + }); + + test("resolveMatchingAnalyticsPreset finds the preset for a resolved range", () => { + const today = new Date("2026-04-08T12:00:00.000Z"); + const preset = getAnalyticsDatePresets().find( + (candidate) => candidate.id === "this-year", + ); + + expect(preset).toBeDefined(); + + if (!preset) { + throw new Error("Expected the this-year preset to exist"); + } + + const resolvedRange = preset.resolveRange(today); + const matchingPreset = resolveMatchingAnalyticsPreset( + resolvedRange.startDate, + resolvedRange.endDate, + today, + ); + + expect(matchingPreset?.id).toBe("this-year"); + }); + + test("formatDashboardDateRangeTriggerLabel formats the selected range", () => { + expect( + formatDashboardDateRangeTriggerLabel("2026-04-01", "2026-04-08"), + ).toBe("Apr 1 - Apr 8, 2026"); + }); +}); diff --git a/apps/web/src/features/analytics/date-range/date-presets.ts b/apps/web/src/features/analytics/date-range/date-presets.ts new file mode 100644 index 00000000..2414f377 --- /dev/null +++ b/apps/web/src/features/analytics/date-range/date-presets.ts @@ -0,0 +1,133 @@ +import { + addDays, + startOfMonth, + startOfQuarter, + startOfWeek, + startOfYear, +} from "date-fns"; +import { getSupportedAnalyticsDateRange } from "@/lib/analytics-date-range"; +import { formatDateRangeLabel, formatIsoDate } from "@/lib/format"; + +export type AnalyticsDatePresetId = + | "last-7-days" + | "last-30-days" + | "last-60-days" + | "last-90-days" + | "this-week" + | "this-month" + | "this-quarter" + | "this-year"; + +export type AnalyticsDatePreset = { + id: AnalyticsDatePresetId; + label: string; + resolveRange: (today: Date) => { startDate: string; endDate: string }; +}; + +function clampStartDate(startDate: Date, endDate: Date) { + const supportedDateRange = getSupportedAnalyticsDateRange(endDate); + + return startDate < supportedDateRange.start + ? supportedDateRange.start + : startDate; +} + +function buildResolvedRange(startDate: Date, endDate: Date) { + const clampedStartDate = clampStartDate(startDate, endDate); + + return { + startDate: formatIsoDate(clampedStartDate), + endDate: formatIsoDate(endDate), + }; +} + +function createRelativeDaysPreset( + id: AnalyticsDatePresetId, + label: string, + daysToSubtract: number, +): AnalyticsDatePreset { + return { + id, + label, + resolveRange: (today) => + buildResolvedRange(addDays(today, -daysToSubtract), today), + }; +} + +export function getAnalyticsDatePresets(): AnalyticsDatePreset[] { + return [ + createRelativeDaysPreset("last-7-days", "Last 7 days", 7), + createRelativeDaysPreset("last-30-days", "Last 30 days", 30), + createRelativeDaysPreset("last-60-days", "Last 60 days", 60), + createRelativeDaysPreset("last-90-days", "Last 90 days", 90), + { + id: "this-week", + label: "This week", + resolveRange: (today) => + buildResolvedRange(startOfWeek(today, { weekStartsOn: 1 }), today), + }, + { + id: "this-month", + label: "This month", + resolveRange: (today) => buildResolvedRange(startOfMonth(today), today), + }, + { + id: "this-quarter", + label: "This quarter", + resolveRange: (today) => buildResolvedRange(startOfQuarter(today), today), + }, + { + id: "this-year", + label: "This year", + resolveRange: (today) => buildResolvedRange(startOfYear(today), today), + }, + ]; +} + +export function parseIsoDateOnly(value: string) { + const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + + if (!dateMatch) { + return null; + } + + const year = Number(dateMatch[1]); + const month = Number(dateMatch[2]); + const day = Number(dateMatch[3]); + const parsedDate = new Date(year, month - 1, day); + + if ( + Number.isNaN(parsedDate.getTime()) || + parsedDate.getFullYear() !== year || + parsedDate.getMonth() !== month - 1 || + parsedDate.getDate() !== day + ) { + return null; + } + + return parsedDate; +} + +export function resolveMatchingAnalyticsPreset( + startDate: string, + endDate: string, + today: Date, +) { + return ( + getAnalyticsDatePresets().find((preset) => { + const resolvedRange = preset.resolveRange(today); + + return ( + resolvedRange.startDate === startDate && + resolvedRange.endDate === endDate + ); + }) ?? null + ); +} + +export function formatDashboardDateRangeTriggerLabel( + startDate: string, + endDate: string, +) { + return formatDateRangeLabel(startDate, endDate); +} diff --git a/apps/web/src/features/analytics/date-range/useDateRange.ts b/apps/web/src/features/analytics/date-range/useDateRange.ts new file mode 100644 index 00000000..b99d8525 --- /dev/null +++ b/apps/web/src/features/analytics/date-range/useDateRange.ts @@ -0,0 +1,58 @@ +import { useSearchParams } from "react-router-dom"; +import { useDateRange as useLegacyDateRange } from "@/contexts/DateRangeContext"; +import { getInclusiveDateRangeDays } from "@/lib/analytics-date-range"; + +const STORAGE_KEY = "dateRange"; + +type DateRangeSource = "default" | "storage" | "url"; + +function readStoredDateRange() { + if (typeof window === "undefined") { + return null; + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +} + +function getDateRangeSource(searchParams: URLSearchParams): DateRangeSource { + if (searchParams.get("from") && searchParams.get("to")) { + return "url"; + } + + const storedRange = readStoredDateRange(); + if (storedRange?.start && storedRange?.end) { + return "storage"; + } + + return "default"; +} + +export function useDateRange() { + const [searchParams] = useSearchParams(); + const { endDate, setEndDate, setStartDate, startDate } = useLegacyDateRange(); + + return { + state: { + endDate, + startDate, + }, + actions: { + setDateRange: (nextStartDate: string, nextEndDate: string) => { + setStartDate(nextStartDate); + setEndDate(nextEndDate); + }, + setEndDate, + setStartDate, + }, + meta: { + dayCount: getInclusiveDateRangeDays(startDate, endDate), + source: getDateRangeSource(searchParams), + }, + }; +} diff --git a/apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx b/apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx new file mode 100644 index 00000000..85b1c9f8 --- /dev/null +++ b/apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { + type AppSession, + getSessionUserEmail, + getSessionUserId, + getSessionUserName, +} from "@/features/auth/auth-route-utils"; +import { + identifyProductAnalyticsUser, + resetProductAnalytics, +} from "@/lib/product-analytics"; + +export function ProductAnalyticsSessionSync({ + session, +}: { + session: AppSession | null | undefined; +}) { + const userId = getSessionUserId(session); + const email = getSessionUserEmail(session); + const name = getSessionUserName(session); + + useEffect(() => { + if (userId) { + identifyProductAnalyticsUser(userId, { + email, + name, + }); + return; + } + + resetProductAnalytics(); + }, [email, name, userId]); + + return null; +} diff --git a/apps/web/src/features/auth/AuthenticatedApp.tsx b/apps/web/src/features/auth/AuthenticatedApp.tsx new file mode 100644 index 00000000..5da73cd0 --- /dev/null +++ b/apps/web/src/features/auth/AuthenticatedApp.tsx @@ -0,0 +1,9 @@ +import { AppRouter } from "@/app/AppRouter"; + +export function AuthenticatedApp({ + rootRedirectTarget, +}: { + rootRedirectTarget: string | null; +}) { + return ; +} diff --git a/apps/web/src/features/auth/DeviceAuthorizationApp.tsx b/apps/web/src/features/auth/DeviceAuthorizationApp.tsx new file mode 100644 index 00000000..62bda11e --- /dev/null +++ b/apps/web/src/features/auth/DeviceAuthorizationApp.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { AppLoadingScreen } from "@/app/bootstrap/AppLoadingScreen"; +import { Button } from "@/components/ui/button"; +import { + type AppSession, + getSessionUserId, +} from "@/features/auth/auth-route-utils"; +import { GuestApp } from "@/features/auth/GuestApp"; +import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics"; + +export function DeviceAuthorizationApp({ + deviceUserCode, + session, +}: { + deviceUserCode: string; + session: AppSession | null; +}) { + const { trackAuthenticationAction } = useAnalyticsTracking({ + pageName: "device_login", + }); + const [deviceProcessing, setDeviceProcessing] = useState(false); + const [deviceApproved, setDeviceApproved] = useState(false); + const [deviceDenied, setDeviceDenied] = useState(false); + const [deviceError, setDeviceError] = useState(null); + + async function submitDeviceDecision(action: "approve" | "deny") { + if (deviceProcessing) { + return; + } + + trackAuthenticationAction({ + actionName: + action === "approve" ? "approve_device_login" : "deny_device_login", + sourceComponent: "device_login", + authMethod: "device_code", + targetId: deviceUserCode, + userId: getSessionUserId(session) ?? undefined, + }); + + setDeviceProcessing(true); + setDeviceError(null); + + try { + const response = await fetch(`/api/auth/device/${action}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userCode: deviceUserCode }), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error_description?: string; + message?: string; + } | null; + + throw new Error( + body?.error_description ?? + body?.message ?? + `Failed to ${action} CLI device login`, + ); + } + + if (action === "approve") { + setDeviceApproved(true); + return; + } + + setDeviceDenied(true); + } catch (error) { + setDeviceError( + error instanceof Error + ? error.message + : "Failed to process device login", + ); + } finally { + setDeviceProcessing(false); + } + } + + if (!session) { + return ; + } + + if (deviceProcessing) { + return ; + } + + if (deviceApproved) { + return ( +
+

CLI login approved

+

+ Return to your terminal to continue. +

+
+ ); + } + + if (deviceDenied) { + return ( +
+

CLI login denied

+

+ This authorization request was not approved. +

+
+ ); + } + + return ( +
+

Authorize CLI login

+

+ User code: {deviceUserCode} +

+ {deviceError ? ( +

{deviceError}

+ ) : ( +

+ Approve this request only if it was initiated by you from the CLI. +

+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/features/auth/GuestApp.tsx b/apps/web/src/features/auth/GuestApp.tsx new file mode 100644 index 00000000..5976096e --- /dev/null +++ b/apps/web/src/features/auth/GuestApp.tsx @@ -0,0 +1,24 @@ +import { useTheme } from "next-themes"; +import { useState } from "react"; +import { LoginForm } from "@/components/auth/login-form"; +import { SignupForm } from "@/components/auth/signup-form"; + +type GuestPage = "login" | "signup"; + +export function GuestApp() { + const [page, setPage] = useState("login"); + const { resolvedTheme } = useTheme(); + const logoSrc = + resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg"; + + return ( +
+ Rudel + {page === "login" ? ( + setPage("signup")} /> + ) : ( + setPage("login")} /> + )} +
+ ); +} diff --git a/apps/web/src/features/auth/ResetPasswordApp.tsx b/apps/web/src/features/auth/ResetPasswordApp.tsx new file mode 100644 index 00000000..99697b75 --- /dev/null +++ b/apps/web/src/features/auth/ResetPasswordApp.tsx @@ -0,0 +1,15 @@ +import { useTheme } from "next-themes"; +import { ResetPasswordForm } from "@/components/auth/reset-password-form"; + +export function ResetPasswordApp() { + const { resolvedTheme } = useTheme(); + const logoSrc = + resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg"; + + return ( +
+ Rudel + (window.location.href = "/")} /> +
+ ); +} diff --git a/apps/web/src/features/auth/auth-route-utils.ts b/apps/web/src/features/auth/auth-route-utils.ts new file mode 100644 index 00000000..102ef422 --- /dev/null +++ b/apps/web/src/features/auth/auth-route-utils.ts @@ -0,0 +1,57 @@ +import type { authClient } from "@/lib/auth-client"; + +export type AppSession = ReturnType["data"]; + +export function getDeviceUserCode(search?: string): string | null { + const params = new URLSearchParams(search ?? window.location.search); + return params.get("user_code"); +} + +export function isResetPasswordPath(pathname?: string): boolean { + return (pathname ?? window.location.pathname) === "/reset-password"; +} + +export function getValidRedirect(search?: string): string | null { + const params = new URLSearchParams(search ?? window.location.search); + const redirect = params.get("redirect"); + + if (!redirect) { + return null; + } + + if (!redirect.startsWith("/") || redirect.startsWith("//")) { + return null; + } + + return redirect; +} + +export function getSessionUserId( + session: AppSession | null | undefined, +): string | null { + return session?.user && + "id" in session.user && + typeof session.user.id === "string" + ? session.user.id + : null; +} + +export function getSessionUserEmail( + session: AppSession | null | undefined, +): string | undefined { + return session?.user && + "email" in session.user && + typeof session.user.email === "string" + ? session.user.email + : undefined; +} + +export function getSessionUserName( + session: AppSession | null | undefined, +): string | undefined { + return session?.user && + "name" in session.user && + typeof session.user.name === "string" + ? session.user.name + : undefined; +} diff --git a/apps/web/src/features/dashboard/DashboardPage.tsx b/apps/web/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 00000000..c33d438b --- /dev/null +++ b/apps/web/src/features/dashboard/DashboardPage.tsx @@ -0,0 +1,83 @@ +import { Tabs, TabsList, TabsTrigger } from "@/app/ui/tabs"; +import { DashboardDateControls } from "@/features/dashboard/components/DashboardDateControls"; +import { DashboardPerformancePanel } from "@/features/dashboard/components/DashboardPerformancePanel"; +import { DashboardRepositoryPanel } from "@/features/dashboard/components/DashboardRepositoryPanel"; +import { useDashboardHomeData } from "@/features/dashboard/use-dashboard-home-data"; +import "@/features/dashboard/dashboard-theme.css"; + +export function DashboardPage() { + const { + isDashboardSnapshotPending, + isPerformanceChartPending, + isRepositoryChartPending, + performanceUserDailyTrend, + performanceUsers, + repositoryDailyTrend, + snapshot, + } = useDashboardHomeData(); + + return ( +
+
+
+
+
+ + + + Tokens + + + Commits + + + Errors + + + Repos + + + Sessions + + + + +
+
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardAnalysisPanel.tsx b/apps/web/src/features/dashboard/components/DashboardAnalysisPanel.tsx new file mode 100644 index 00000000..7206a74e --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardAnalysisPanel.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type DashboardAnalysisPanelProps = { + title: string; + icon?: ReactNode; + titleLevel?: "h2" | "h3"; + controls?: ReactNode; + chartContent: ReactNode; + tableContent: ReactNode; + className?: string; + chartCardClassName?: string; + chartInnerClassName?: string; + chartShellClassName?: string; + chartShellDataSlot?: string; + tableSectionClassName?: string; + showTableDivider?: boolean; +}; + +export function DashboardAnalysisPanel({ + title, + icon, + titleLevel = "h2", + controls, + chartContent, + tableContent, + className, + chartCardClassName, + chartInnerClassName, + chartShellClassName, + chartShellDataSlot, + tableSectionClassName, + showTableDivider = true, +}: DashboardAnalysisPanelProps) { + const TitleTag = titleLevel; + + return ( +
+
+
+
+ {icon} + + {title} + +
+ {controls} +
+
+
+
+ {chartContent} +
+
+
+
+
+ {tableContent} +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardDailyOverviewTable.tsx b/apps/web/src/features/dashboard/components/DashboardDailyOverviewTable.tsx new file mode 100644 index 00000000..90ae334e --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardDailyOverviewTable.tsx @@ -0,0 +1,186 @@ +import { format, parseISO } from "date-fns"; +import type { DashboardDailyPatternPoint } from "@/features/dashboard/data/dashboard-static-data"; +import { cn } from "@/lib/utils"; + +type DashboardDailyOverviewRow = { + commitRate: number | null; + commits: number | null; + dateLabel: string; + dayLabel: string; + id: string; + sessions: number | null; + statusLabel: string; + statusTone: "danger" | "muted" | "success" | "warning"; +}; + +const MAX_DAILY_ROWS = 10; + +function getStatusTone(commitRate: number | null) { + if (commitRate == null) { + return { + label: "No activity", + tone: "muted" as const, + }; + } + + if (commitRate >= 65) { + return { + label: "High throughput", + tone: "success" as const, + }; + } + + if (commitRate >= 45) { + return { + label: "Steady", + tone: "warning" as const, + }; + } + + return { + label: "Needs review", + tone: "danger" as const, + }; +} + +function getToneClasses(tone: DashboardDailyOverviewRow["statusTone"]) { + switch (tone) { + case "success": + return { + dotClassName: "bg-[color:var(--dashboardy-success-foreground)]", + textClassName: "text-[color:var(--dashboardy-success-foreground)]", + }; + case "warning": + return { + dotClassName: "bg-[color:var(--dashboardy-warning-foreground)]", + textClassName: "text-[color:var(--dashboardy-warning-foreground)]", + }; + case "danger": + return { + dotClassName: "bg-[color:var(--dashboardy-danger-foreground)]", + textClassName: "text-[color:var(--dashboardy-danger-foreground)]", + }; + case "muted": + return { + dotClassName: "bg-[color:var(--dashboardy-subtle)]", + textClassName: "text-[color:var(--dashboardy-muted)]", + }; + } +} + +function buildDailyRows( + data: DashboardDailyPatternPoint[], +): DashboardDailyOverviewRow[] { + return data + .slice(-MAX_DAILY_ROWS) + .reverse() + .map((point) => { + const parsedDate = parseISO(point.date); + const safeDateLabel = Number.isNaN(parsedDate.getTime()) + ? point.date + : format(parsedDate, "MMM d"); + const safeDayLabel = Number.isNaN(parsedDate.getTime()) + ? point.axisLabel + : format(parsedDate, "EEEE"); + const status = getStatusTone(point.commitRate); + + return { + commitRate: point.commitRate, + commits: point.commits, + dateLabel: safeDateLabel, + dayLabel: safeDayLabel, + id: point.date, + sessions: point.sessions, + statusLabel: status.label, + statusTone: status.tone, + }; + }); +} + +export function DashboardDailyOverviewTable({ + data, + highlightedDate, + highlightSource, + onHighlightDateChange, +}: { + data: DashboardDailyPatternPoint[]; + highlightedDate?: string | null; + highlightSource?: "chart" | "table" | null; + onHighlightDateChange?: (date: string | null) => void; +}) { + const rows = buildDailyRows(data); + const hasTableHighlight = + highlightSource === "table" && highlightedDate != null; + const hasChartHighlight = + highlightSource === "chart" && highlightedDate != null; + + return ( +
+
+
+

Day

+

Sessions

+

Commits

+

Rate

+

Overview

+
+
+ {rows.map((row) => { + const tone = getToneClasses(row.statusTone); + const isHighlighted = highlightedDate === row.id; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardDailyPatternChart.tsx b/apps/web/src/features/dashboard/components/DashboardDailyPatternChart.tsx new file mode 100644 index 00000000..1e69e926 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardDailyPatternChart.tsx @@ -0,0 +1,527 @@ +"use client"; + +import type { RepositoryDailyTrendData } from "@rudel/api-routes"; +import { format, parseISO } from "date-fns"; +import { useMemo } from "react"; +import { Bar, BarChart, Rectangle, XAxis, YAxis } from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart"; +import { DASHBOARD_REPOSITORY_TREND_COLORS } from "@/features/dashboard/data/dashboard-repository-trend"; +import type { DashboardDailyPatternPoint } from "@/features/dashboard/data/dashboard-static-data"; +import { cn } from "@/lib/utils"; + +const commitFlowChartConfig = { + committed: { + label: "Committed sessions", + color: "#1949A9", + }, + uncommitted: { + label: "Uncommitted sessions", + color: "#C21674", + }, +} satisfies ChartConfig; + +const commitFlowStackOrder = ["committed", "uncommitted"] as const; + +type DailyPatternChartMode = "commit-flow" | "repository-stack"; +type ChartPointRecord = DashboardDailyPatternPoint & + Record; +type RepositoryStackSeries = { + color: string; + key: string; + label: string; + totalSessions: number; +}; + +function getAxisMax(data: DashboardDailyPatternPoint[]) { + const maxSessions = Math.max(...data.map((point) => point.sessions ?? 0), 0); + + return Math.max(15, Math.ceil(maxSessions / 15) * 15); +} + +function buildCommitFlowChartData( + data: DashboardDailyPatternPoint[], +): ChartPointRecord[] { + return data.map((point) => { + if (point.sessions == null || point.commits == null) { + return { + ...point, + committed: 0, + uncommitted: 0, + }; + } + + return { + ...point, + committed: point.commits, + uncommitted: Math.max(point.sessions - point.commits, 0), + }; + }); +} + +function buildRepositoryStackData( + data: DashboardDailyPatternPoint[], + repositoryDailyTrend: RepositoryDailyTrendData[], +) { + const repositoryTotals = new Map(); + + for (const row of repositoryDailyTrend) { + repositoryTotals.set( + row.repository, + (repositoryTotals.get(row.repository) ?? 0) + row.sessions, + ); + } + + const repositorySeries = Array.from(repositoryTotals.entries()) + .sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .map(([label, totalSessions], index) => ({ + color: + DASHBOARD_REPOSITORY_TREND_COLORS[ + index % DASHBOARD_REPOSITORY_TREND_COLORS.length + ], + key: `repository-${index + 1}`, + label, + totalSessions, + })) satisfies RepositoryStackSeries[]; + + const sessionsByDate = new Map( + repositoryDailyTrend.map( + (row) => [`${row.repository}:${row.date}`, row.sessions] as const, + ), + ); + + const chartData = data.map((point) => { + const nextPoint: ChartPointRecord = { ...point }; + + for (const series of repositorySeries) { + nextPoint[series.key] = + sessionsByDate.get(`${series.label}:${point.date}`) ?? 0; + } + + return nextPoint; + }); + + const chartConfig = Object.fromEntries( + repositorySeries.map((series) => [ + series.key, + { + label: series.label, + color: series.color, + }, + ]), + ) satisfies ChartConfig; + + return { + chartConfig, + chartData, + repositorySeries, + stackOrder: repositorySeries.map((series) => series.key), + }; +} + +function getTickLabel( + dateValue: string, + index: number, + total: number, + activeDate?: string | null, +) { + const parsedDate = parseISO(dateValue); + + if (Number.isNaN(parsedDate.getTime())) { + return ""; + } + + if (activeDate != null) { + return activeDate === dateValue + ? total <= 7 + ? format(parsedDate, "EEE d") + : format(parsedDate, "MMM d") + : ""; + } + + const isFirstTick = index === 0; + const isLastTick = index === total - 1; + + if (!isFirstTick && !isLastTick) { + return ""; + } + + return total <= 7 ? format(parsedDate, "EEE d") : format(parsedDate, "MMM d"); +} + +function getBarSize(total: number) { + if (total <= 7) { + return 32; + } + + if (total <= 14) { + return 24; + } + + if (total <= 21) { + return 18; + } + + if (total <= 31) { + return 14; + } + + return 10; +} + +function getBarCategoryGap(total: number) { + if (total <= 7) { + return 0; + } + + if (total <= 14) { + return 0; + } + + if (total <= 21) { + return 0; + } + + if (total <= 31) { + return 0; + } + + return 0; +} + +function DailyPatternTooltip({ + active, + mode, + payload, +}: { + active?: boolean; + mode: DailyPatternChartMode; + payload?: Array<{ + color?: string; + dataKey?: string; + name?: string; + value?: number | string; + payload: ChartPointRecord; + }>; +}) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + + if (!point) { + return null; + } + + if (mode === "repository-stack") { + const repositoryItems = payload + .map((item) => ({ + color: item.color, + label: String(item.name ?? item.dataKey ?? "Repository"), + value: + typeof item.value === "number" ? item.value : Number(item.value ?? 0), + })) + .filter((item) => Number.isFinite(item.value) && item.value > 0) + .sort((left, right) => right.value - left.value); + const totalSessions = + point.sessions ?? + repositoryItems.reduce((sum, item) => sum + item.value, 0); + + return ( +
+

{point.fullLabel}

+
+ Sessions + {totalSessions} +
+
+ Active repos + + {repositoryItems.length} + +
+ {repositoryItems.length > 0 ? ( +
+ {repositoryItems.map((item) => ( +
+
+
+ + {item.value} + +
+ ))} +
+ ) : null} +
+ ); + } + + return ( +
+

{point.fullLabel}

+
+ Sessions + + {point.sessions == null ? "-" : point.sessions} + +
+
+ Commit rate + + {point.commitRate == null ? "-" : `${point.commitRate}%`} + +
+
+ Committed + + {point.commits == null ? "-" : point.commits} + +
+
+ Uncommitted + + {Math.max((point.sessions ?? 0) - (point.commits ?? 0), 0)} + +
+
+ ); +} + +function DailyBarShape(props: { + activeDate?: string | null; + activeSource?: "chart" | "table" | null; + dataKey?: string; + fill?: string; + height?: number; + payload?: ChartPointRecord; + stackOrder: readonly string[]; + width?: number; + x?: number; + y?: number; +}) { + const { fill, x, y, width, height, payload, dataKey } = props; + + if ( + typeof x !== "number" || + typeof y !== "number" || + typeof width !== "number" || + typeof height !== "number" || + !payload || + !dataKey || + height <= 0 + ) { + return null; + } + + const keyIndex = props.stackOrder.indexOf(dataKey); + const isTopSegment = props.stackOrder + .slice(keyIndex + 1) + .every((key) => Number(payload[key] ?? 0) === 0); + const isHighlighted = props.activeDate === payload.date; + const hasExternalHighlight = props.activeDate != null; + const isTableHighlight = props.activeSource === "table"; + const highlightStroke = + "color-mix(in srgb, var(--dashboardy-heading) 22%, transparent)"; + const barOpacity = + hasExternalHighlight && !isHighlighted + ? isTableHighlight + ? 0.16 + : 0.26 + : 1; + const showStroke = isHighlighted && isTopSegment; + + return ( + + ); +} + +export function DashboardDailyPatternChart({ + data, + className, + highlightedDate, + highlightSource, + onHighlightDateChange, + mode = "commit-flow", + repositoryDailyTrend, +}: { + data: DashboardDailyPatternPoint[]; + className?: string; + highlightedDate?: string | null; + highlightSource?: "chart" | "table" | null; + onHighlightDateChange?: (date: string | null) => void; + mode?: DailyPatternChartMode; + repositoryDailyTrend?: RepositoryDailyTrendData[] | undefined; +}) { + const hasRepositoryStackData = + mode === "repository-stack" && (repositoryDailyTrend?.length ?? 0) > 0; + const repositoryStackData = useMemo( + () => + hasRepositoryStackData + ? buildRepositoryStackData(data, repositoryDailyTrend ?? []) + : null, + [data, hasRepositoryStackData, repositoryDailyTrend], + ); + const commitFlowChartData = useMemo( + () => buildCommitFlowChartData(data), + [data], + ); + const chartData = hasRepositoryStackData + ? (repositoryStackData?.chartData ?? []) + : commitFlowChartData; + const chartConfig = hasRepositoryStackData + ? (repositoryStackData?.chartConfig ?? {}) + : commitFlowChartConfig; + const stackOrder = hasRepositoryStackData + ? (repositoryStackData?.stackOrder ?? []) + : [...commitFlowStackOrder]; + const axisMax = getAxisMax(data); + const axisTicks = Array.from( + { length: Math.floor(axisMax / 15) + 1 }, + (_, index) => index * 15, + ); + const barSize = getBarSize(chartData.length); + const barCategoryGap = getBarCategoryGap(chartData.length); + const chartInteractionProps = onHighlightDateChange + ? { + onMouseLeave: () => onHighlightDateChange(null), + onMouseMove: (state: { activeLabel?: unknown }) => { + onHighlightDateChange( + typeof state.activeLabel === "string" ? state.activeLabel : null, + ); + }, + } + : undefined; + + return ( +
+ + + + getTickLabel( + String(value), + index, + chartData.length, + highlightedDate, + ) + } + tickLine={false} + tickMargin={4} + tick={{ + fontSize: 12, + fontWeight: 500, + fill: "var(--dashboardy-muted)", + opacity: 0.38, + }} + /> + + } + /> + {hasRepositoryStackData + ? (repositoryStackData?.repositorySeries ?? []).map((series) => ( + + } + /> + )) + : commitFlowStackOrder.map((seriesKey) => ( + + } + /> + ))} + + +
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardDailySnapshotSection.tsx b/apps/web/src/features/dashboard/components/DashboardDailySnapshotSection.tsx new file mode 100644 index 00000000..d8f22af6 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardDailySnapshotSection.tsx @@ -0,0 +1,66 @@ +import type { RepositoryDailyTrendData } from "@rudel/api-routes"; +import type { ReactNode } from "react"; +import { DashboardDailyOverviewTable } from "@/features/dashboard/components/DashboardDailyOverviewTable"; +import { DashboardDailyPatternChart } from "@/features/dashboard/components/DashboardDailyPatternChart"; +import { + DashboardInteractiveTopChartSection, + type DashboardTopChartRenderProps, +} from "@/features/dashboard/components/DashboardTopChartSection"; +import type { + DashboardDailyPatternPoint, + DashboardHeadlineMetric, +} from "@/features/dashboard/data/dashboard-static-data"; + +export function DashboardDailySnapshotSection({ + chartMode = "commit-flow", + dailyPattern, + isMetricsLoading = false, + metrics, + renderDetail, + repositoryDailyTrend, + showDelta = false, +}: { + chartMode?: "commit-flow" | "repository-stack"; + dailyPattern: DashboardDailyPatternPoint[]; + isMetricsLoading?: boolean; + metrics: DashboardHeadlineMetric[]; + renderDetail?: (props: DashboardTopChartRenderProps) => ReactNode; + repositoryDailyTrend?: RepositoryDailyTrendData[] | undefined; + showDelta?: boolean; +}) { + return ( + ( + + )} + renderDetail={({ + highlightedItemId, + highlightSource, + onHighlightItemChange, + }) => + renderDetail?.({ + highlightedItemId, + highlightSource, + onHighlightItemChange, + }) ?? ( + + ) + } + /> + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardDateControls.tsx b/apps/web/src/features/dashboard/components/DashboardDateControls.tsx new file mode 100644 index 00000000..22567222 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardDateControls.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { AnalyticsDateRangePicker } from "@/features/analytics/date-range/components/AnalyticsDateRangePicker"; +import { useDateRange } from "@/features/analytics/date-range/useDateRange"; +import { cn } from "@/lib/utils"; + +export function DashboardDateControls({ className }: { className?: string }) { + const { state, actions } = useDateRange(); + + return ( + + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardDeveloperPanel.tsx b/apps/web/src/features/dashboard/components/DashboardDeveloperPanel.tsx new file mode 100644 index 00000000..1622d71d --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardDeveloperPanel.tsx @@ -0,0 +1,305 @@ +import type { UserDailyTrendData } from "@rudel/api-routes"; +import { GaugeIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Skeleton } from "@/app/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/app/ui/toggle-group"; +import { DashboardAnalysisPanel } from "@/features/dashboard/components/DashboardAnalysisPanel"; +import { + DashboardPerformanceChart, + type DashboardPerformanceDatum, +} from "@/features/dashboard/components/DashboardPerformanceChart"; +import { DashboardPerformanceRosterTable } from "@/features/dashboard/components/DashboardPerformanceRosterTable"; +import { DashboardPerformanceTrendChart } from "@/features/dashboard/components/DashboardPerformanceTrendChart"; +import { + DashboardTokenDeveloperChart, + type DashboardTokenDeveloperDatum, +} from "@/features/dashboard/components/DashboardTokenDeveloperChart"; +import type { DashboardPerformanceUserComparison } from "@/features/dashboard/data/dashboard-performance-adapter"; +import { + buildDashboardPerformanceTrendSeries, + type DashboardPerformanceTrendMetric, +} from "@/features/dashboard/data/dashboard-performance-trend"; +import { cn } from "@/lib/utils"; + +type PerformanceChartView = "total" | "over-time"; +type DashboardDeveloperPanelVariant = "commits" | "repositories"; + +const MAX_VISIBLE_PERFORMANCE_BARS = 20; + +function getMemberAxisLabel(fullLabel: string) { + const emailSafeLabel = fullLabel.includes("@") + ? (fullLabel.split("@")[0] ?? fullLabel) + : fullLabel; + + return emailSafeLabel.split(" ")[0] ?? emailSafeLabel; +} + +function getMemberAxisLabels(memberLabels: string[]) { + const labelCounts = new Map(); + + for (const fullLabel of memberLabels) { + const axisLabel = getMemberAxisLabel(fullLabel); + labelCounts.set(axisLabel, (labelCounts.get(axisLabel) ?? 0) + 1); + } + + return memberLabels.map((fullLabel) => { + const axisLabel = getMemberAxisLabel(fullLabel); + + if ((labelCounts.get(axisLabel) ?? 0) <= 1) { + return axisLabel; + } + + const fallbackToken = fullLabel.includes("@") + ? (fullLabel.split("@")[0] ?? fullLabel) + : fullLabel; + const [firstName, lastName] = fallbackToken.split(/\s+/); + const lastInitial = lastName?.[0]?.toUpperCase(); + + return lastInitial ? `${firstName} ${lastInitial}.` : fallbackToken; + }); +} + +function buildChartData( + performanceUsers: DashboardPerformanceUserComparison[], +): DashboardPerformanceDatum[] { + const axisLabels = getMemberAxisLabels( + performanceUsers.map((user) => user.label), + ); + + return performanceUsers.map((user, index) => ({ + commits: user.commits, + id: + user.userId || + `${user.label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-${index}`, + axisLabel: axisLabels[index] ?? user.label, + fullLabel: user.label, + imageUrl: user.imageUrl ?? undefined, + sessions: user.sessions, + })); +} + +function buildRepositoryBreadthChartData( + performanceUsers: DashboardPerformanceUserComparison[], +): DashboardTokenDeveloperDatum[] { + const axisLabels = getMemberAxisLabels( + performanceUsers.map((user) => user.label), + ); + + return performanceUsers.map((user, index) => ({ + axisLabel: axisLabels[index] ?? user.label, + fullLabel: user.label, + id: + user.userId || + `${user.label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-${index}`, + imageUrl: user.imageUrl ?? undefined, + sessions: user.sessions, + totalTokens: user.repositoriesTouched.length, + })); +} + +function DashboardPerformanceChartFallback() { + const skeletonHeights = [ + "h-[8.25rem]", + "h-[10rem]", + "h-[7rem]", + "h-[12rem]", + "h-[9rem]", + "h-[11rem]", + "h-[8rem]", + ]; + + return ( +
+ {skeletonHeights.map((heightClassName) => ( +
+ + +
+ ))} +
+ ); +} + +export function DashboardDeveloperPanel({ + isChartPending, + performanceUserDailyTrend, + performanceUsers, + variant = "commits", +}: { + isChartPending: boolean; + performanceUserDailyTrend: UserDailyTrendData[] | undefined; + performanceUsers: DashboardPerformanceUserComparison[]; + variant?: DashboardDeveloperPanelVariant; +}) { + const [chartView, setChartView] = useState("total"); + const [hiddenTrendSeriesIds, setHiddenTrendSeriesIds] = useState( + [], + ); + const [highlightedUserId, setHighlightedUserId] = useState( + null, + ); + const [trendMetric, setTrendMetric] = + useState( + variant === "repositories" ? "repositories" : "sessions", + ); + const repositoryChartData = useMemo( + () => + buildRepositoryBreadthChartData( + [...performanceUsers].sort( + (left, right) => + right.repositoriesTouched.length - + left.repositoriesTouched.length || + right.sessions - left.sessions || + left.label.localeCompare(right.label), + ), + ).slice(0, MAX_VISIBLE_PERFORMANCE_BARS), + [performanceUsers], + ); + const commitChartData = useMemo( + () => + buildChartData(performanceUsers).slice(0, MAX_VISIBLE_PERFORMANCE_BARS), + [performanceUsers], + ); + const hasChartData = + variant === "repositories" + ? repositoryChartData.length > 0 + : commitChartData.length > 0; + const hasTrendData = useMemo( + () => (performanceUserDailyTrend?.length ?? 0) > 0, + [performanceUserDailyTrend], + ); + const trendSeries = useMemo( + () => + buildDashboardPerformanceTrendSeries( + variant === "repositories" + ? [...performanceUsers].sort( + (left, right) => + right.repositoriesTouched.length - + left.repositoriesTouched.length || + right.sessions - left.sessions || + left.label.localeCompare(right.label), + ) + : performanceUsers, + performanceUserDailyTrend, + trendMetric, + ), + [performanceUserDailyTrend, performanceUsers, trendMetric, variant], + ); + + function handleToggleTrendSeries(userId: string) { + setHiddenTrendSeriesIds((currentIds) => + currentIds.includes(userId) + ? currentIds.filter((id) => id !== userId) + : [...currentIds, userId], + ); + } + + return ( + + } + chartShellDataSlot="dashboard-performance-chart-shell" + controls={ + { + const nextView = nextValue[0]; + + if (nextView === "total" || nextView === "over-time") { + setChartView(nextView); + } + }} + > + + Total + + + Over time + + + } + chartContent={ + isChartPending ? ( + + ) : chartView === "over-time" ? ( + hasTrendData ? ( + + ) : ( +
+ {variant === "repositories" + ? "No repository activity in the selected range." + : "No developer activity in the selected range."} +
+ ) + ) : hasChartData ? ( + variant === "repositories" ? ( + value.toLocaleString()} + formatSecondaryValue={(value) => value.toLocaleString()} + formatDerivedValue={(primaryValue, secondaryValue) => + primaryValue > 0 + ? Math.round(secondaryValue / primaryValue).toLocaleString() + : "—" + } + yAxisTickFormatter={(value) => Math.round(value).toLocaleString()} + /> + ) : ( + + ) + ) : ( +
+ {variant === "repositories" + ? "No repository activity in the selected range." + : "No developer activity in the selected range."} +
+ ) + } + tableContent={ + + } + /> + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardGridTable.tsx b/apps/web/src/features/dashboard/components/DashboardGridTable.tsx new file mode 100644 index 00000000..819d2d05 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardGridTable.tsx @@ -0,0 +1,294 @@ +import type { ReactNode } from "react"; +import { useState } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/app/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/app/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type DashboardGridTableColumn = { + id: string; + header: ReactNode; + renderCell: (row: T) => ReactNode; + headerClassName?: string; + cellClassName?: string | ((row: T, index: number) => string | undefined); +}; + +type DashboardGridTableProps = { + columns: DashboardGridTableColumn[]; + rows: T[]; + rowKey: (row: T) => string; + gridTemplateColumns: string; + minWidthClassName: string; + className?: string; + headerClassName?: string; + bodyClassName?: string; + rowClassName?: string | ((row: T, index: number) => string | undefined); + emptyState?: ReactNode; + loadingState?: ReactNode; + footer?: ReactNode; + onRowHoverChange?: (rowId: string | null) => void; + getHoverRowId?: (row: T) => string; + onRowClick?: (row: T) => void; + isRowSelected?: (row: T) => boolean; +}; + +type DashboardCellStackProps = { + primary: ReactNode; + secondary?: ReactNode; + primaryClassName?: string; + secondaryClassName?: string; +}; + +type DashboardInlineOverflowListProps = { + visibleItems: string[]; + hiddenItems: string[]; + overflowLabel: string; + mode?: "tooltip" | "popover" | "plain"; +}; + +type DashboardTableFooterNoteProps = { + children: ReactNode; + align?: "right" | "left"; +}; + +function DashboardInlineOverflowPopover({ + hiddenItems, + overflowLabel, +}: { + hiddenItems: string[]; + overflowLabel: string; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + {overflowLabel} + + setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > +
+ {hiddenItems.map((item) => ( +

+ {item} +

+ ))} +
+
+
+ ); +} + +export function DashboardCellStack({ + primary, + secondary, + primaryClassName, + secondaryClassName, +}: DashboardCellStackProps) { + return ( +
+
+ {primary} +
+ {secondary != null ? ( +
+ {secondary} +
+ ) : null} +
+ ); +} + +export function DashboardInlineOverflowList({ + visibleItems, + hiddenItems, + overflowLabel, + mode = "tooltip", +}: DashboardInlineOverflowListProps) { + const hasHiddenItems = hiddenItems.length > 0; + const hasVisibleItems = visibleItems.length > 0; + + return ( + <> + {visibleItems.map((item, index) => ( + + {index > 0 ? ", " : null} + {item} + + ))} + {hasHiddenItems ? ( + <> + {hasVisibleItems ? ", " : null} + {mode === "plain" ? ( + + {overflowLabel} + + ) : mode === "popover" ? ( + + ) : ( + + + } + > + {overflowLabel} + + +
+ {hiddenItems.map((item) => ( +

{item}

+ ))} +
+
+
+ )} + + ) : null} + + ); +} + +export function DashboardTableFooterNote({ + children, + align = "right", +}: DashboardTableFooterNoteProps) { + return ( +
+ {children} +
+ ); +} + +export function DashboardGridTable({ + columns, + rows, + rowKey, + gridTemplateColumns, + minWidthClassName, + className, + headerClassName, + bodyClassName, + rowClassName, + emptyState, + loadingState, + footer, + onRowHoverChange, + getHoverRowId, + onRowClick, + isRowSelected, +}: DashboardGridTableProps) { + if (rows.length === 0) { + return loadingState ?? emptyState ?? null; + } + + return ( +
+
+
+ {columns.map((column) => ( +
+ {column.header} +
+ ))} +
+
onRowHoverChange?.(null)} + > + {rows.map((row, index) => { + const key = rowKey(row); + const resolvedRowClassName = + typeof rowClassName === "function" + ? rowClassName(row, index) + : rowClassName; + const hoverId = getHoverRowId?.(row); + const isSelected = isRowSelected?.(row) ?? false; + const sharedProps = { + "data-dashboard-grid-hover-id": hoverId, + "data-selected": isSelected ? "true" : undefined, + className: cn( + "grid min-h-12 items-center gap-6 rounded-lg px-3.5 py-2 text-sm odd:bg-[color:var(--dashboardy-subsurface-strong)]", + onRowClick && "text-left transition-colors duration-200", + onRowHoverChange && + "focus:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--dashboardy-border)] focus-visible:ring-offset-0", + resolvedRowClassName, + ), + onMouseEnter: () => onRowHoverChange?.(hoverId ?? null), + onFocus: () => onRowHoverChange?.(hoverId ?? null), + onBlur: () => onRowHoverChange?.(null), + tabIndex: onRowHoverChange && !onRowClick ? 0 : undefined, + style: { gridTemplateColumns }, + }; + + const cells = columns.map((column) => { + const resolvedCellClassName = + typeof column.cellClassName === "function" + ? column.cellClassName(row, index) + : column.cellClassName; + + return ( +
+ {column.renderCell(row)} +
+ ); + }); + + return onRowClick ? ( + + ) : ( +
+ {cells} +
+ ); + })} +
+ {footer} +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardHeadlineMetricGrid.tsx b/apps/web/src/features/dashboard/components/DashboardHeadlineMetricGrid.tsx new file mode 100644 index 00000000..e0a764e5 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardHeadlineMetricGrid.tsx @@ -0,0 +1,80 @@ +import type { + DashboardDeltaTone, + DashboardHeadlineMetric, +} from "@/features/dashboard/data/dashboard-static-data"; +import { cn } from "@/lib/utils"; + +const deltaToneClass: Record = { + positive: "text-status-success-icon", + negative: "text-status-error-icon", + neutral: "text-[color:var(--dashboardy-muted)]", +}; + +const metricBarClass: Record = { + sessions: "bg-[color:var(--dashboard-01-tone-blue)]", + uncommitted: "bg-[#C21674]", + commitRate: "bg-[color:var(--dashboard-01-tone-teal)]", +}; + +export function DashboardHeadlineMetricGrid({ + metrics, + className, + isLoading = false, + showDelta = true, +}: { + metrics: DashboardHeadlineMetric[]; + className?: string; + isLoading?: boolean; + showDelta?: boolean; +}) { + if (isLoading) { + return ( +
+
+
+

+ Counting... +

+
+
+
+ ); + } + + return ( +
+
+ {metrics.map((metric) => ( +
+
+

+ {metric.valueLabel} +

+ {showDelta ? ( + + {metric.deltaLabel} + + ) : null} +
+
+ +

+ {metric.label} +

+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardModelBadges.tsx b/apps/web/src/features/dashboard/components/DashboardModelBadges.tsx new file mode 100644 index 00000000..8fe3a2d4 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardModelBadges.tsx @@ -0,0 +1,161 @@ +type ModelBadgeTone = { + chipClassName: string; + icon: "claude" | "codex" | null; +}; + +function normalizeModelVersion(version: string | null | undefined) { + if (!version) { + return null; + } + + return version + .replaceAll(/[-_]/g, ".") + .replaceAll(/\.+/g, ".") + .replaceAll(/^\./g, "") + .replaceAll(/\.$/g, ""); +} + +function formatFallbackModelLabel(model: string) { + return model + .replaceAll(/[-_]+/g, " ") + .replaceAll(/\s+/g, " ") + .trim() + .replaceAll(/\b\w/g, (char) => char.toUpperCase()); +} + +function formatModelDisplayLabel(model: string) { + const normalizedModel = model.trim().toLowerCase(); + const claudeFamilyMatch = normalizedModel.match(/(opus|sonnet|haiku)/); + + if (claudeFamilyMatch) { + const familyLabel = + claudeFamilyMatch[1][0]?.toUpperCase() + + (claudeFamilyMatch[1].slice(1) ?? ""); + const versionAfterFamily = normalizedModel.match( + /(?:opus|sonnet|haiku)[-_ ]?([0-9]+(?:[._-][0-9]+)?)/, + )?.[1]; + const versionBeforeFamily = normalizedModel.match( + /claude[-_ ]?([0-9]+(?:[._-][0-9]+)?(?:[-_][0-9]+(?:\.[0-9]+)?)?)[-_ ]?(?:opus|sonnet|haiku)/, + )?.[1]; + const version = + normalizeModelVersion(versionAfterFamily) ?? + normalizeModelVersion(versionBeforeFamily); + + return version ? `${familyLabel} ${version}` : familyLabel; + } + + if ( + normalizedModel.includes("gpt") || + normalizedModel.includes("chatgpt") || + normalizedModel.includes("codex") + ) { + const version = normalizeModelVersion( + normalizedModel.match(/gpt[-_ ]?([0-9]+(?:[._-][0-9]+)?)/)?.[1] ?? + normalizedModel.match( + /(?:chatgpt|codex)[-_ ]?([0-9]+(?:[._-][0-9]+)?)/, + )?.[1], + ); + + return version ? `GPT ${version}` : "GPT"; + } + + return formatFallbackModelLabel(model); +} + +function shouldHideModelBadge(model: string) { + const normalizedModel = model + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, ""); + + return normalizedModel.includes("synthetic"); +} + +function ClaudeModelIcon() { + return ( + + ); +} + +function CodexModelIcon() { + return ( + + ); +} + +function getModelBadgeTone(model: string): ModelBadgeTone { + const normalizedModel = model.toLowerCase(); + + if (normalizedModel.includes("claude")) { + return { + chipClassName: + "border-transparent bg-[#CC7D5E] text-[#F9F9F7] shadow-none", + icon: "claude", + }; + } + + if (normalizedModel.includes("codex")) { + return { + chipClassName: "border-black/10 bg-[#FFFFFF] text-[#111111] shadow-none", + icon: "codex", + }; + } + + if (normalizedModel.includes("chatgpt") || normalizedModel.includes("gpt")) { + return { + chipClassName: "border-black/10 bg-[#FFFFFF] text-[#111111] shadow-none", + icon: "codex", + }; + } + + return { + chipClassName: + "border-[color:var(--dashboardy-chip-border)] bg-[color:var(--dashboardy-chip-surface)] text-[color:var(--dashboardy-chip-foreground)]", + icon: null, + }; +} + +export function DashboardModelBadges({ models }: { models: string[] }) { + const visibleModels = models.filter((model) => !shouldHideModelBadge(model)); + + if (visibleModels.length === 0) { + return ( + + — + + ); + } + + return ( + <> + {visibleModels.map((model) => { + const badgeTone = getModelBadgeTone(model); + const modelLabel = formatModelDisplayLabel(model); + + return ( + + {badgeTone.icon === "claude" ? ( + + ) : badgeTone.icon === "codex" ? ( + + ) : null} + {modelLabel} + + ); + })} + + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx b/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx new file mode 100644 index 00000000..04dc5fde --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useMemo } from "react"; +import { Bar, BarChart, XAxis, YAxis } from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart"; +import { DashboardStackedTopRoundedBar } from "@/features/dashboard/components/DashboardStackedTopRoundedBar"; +import { + getDashboardBarLabelWidth, + getDashboardBarSize, +} from "@/features/dashboard/components/dashboard-bar-chart-layout"; + +const chartConfig = { + committed: { + label: "Committed sessions", + color: "#1949A9", + }, + uncommitted: { + label: "Uncommitted sessions", + color: "#C21674", + }, +} satisfies ChartConfig; + +const ZERO_BAR_STUB_VALUE = 0.75; + +export type DashboardPerformanceDatum = { + axisLabel: string; + commits: number; + fullLabel: string; + id: string; + imageUrl?: string; + sessions: number; +}; + +type DashboardPerformanceChartProps = { + activeId?: string | null; + data: DashboardPerformanceDatum[]; +}; + +type DashboardPerformanceChartRow = DashboardPerformanceDatum & { + committed: number; + stub: number; + uncommitted: number; +}; + +function getAvatarInitials(fullLabel: string) { + const fallbackToken = fullLabel.includes("@") + ? (fullLabel.split("@")[0] ?? fullLabel) + : fullLabel; + const parts = fallbackToken.split(/\s+/).filter(Boolean); + + if (parts.length === 0) { + return "AI"; + } + + if (parts.length === 1) { + return parts[0]?.slice(0, 2).toUpperCase() ?? "AI"; + } + + return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase(); +} + +function getAxisMax(data: DashboardPerformanceChartRow[]) { + const maxSessions = Math.max(...data.map((point) => point.sessions), 0); + + if (maxSessions <= 0) { + return 15; + } + + if (maxSessions <= 30) { + return Math.ceil(maxSessions / 5) * 5; + } + + if (maxSessions <= 90) { + return Math.ceil(maxSessions / 10) * 10; + } + + return Math.ceil(maxSessions / 20) * 20; +} + +function getAxisTicks(axisMax: number) { + const step = + axisMax <= 30 ? 5 : axisMax <= 90 ? 10 : axisMax <= 180 ? 20 : 40; + const ticks = Array.from( + { length: Math.floor(axisMax / step) + 1 }, + (_, index) => index * step, + ); + + return ticks.length > 1 ? ticks : [0, axisMax]; +} + +function DashboardPerformanceTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: Array<{ payload: DashboardPerformanceChartRow }>; +}) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + + if (!point) { + return null; + } + + const commitRate = + point.sessions > 0 + ? Math.round((point.committed / point.sessions) * 100) + : 0; + + return ( +
+
{point.fullLabel}
+
+
+ Sessions + + {point.sessions} + +
+
+ Commit rate + + {commitRate}% + +
+
+ Committed sessions + + {point.committed} + +
+
+ Uncommitted sessions + + {point.uncommitted} + +
+
+
+ ); +} + +function DashboardPerformanceAxisTick({ + activeId, + dataById, + labelWidth, + payload, + x = 0, + y = 0, +}: { + activeId?: string | null; + dataById: Map; + labelWidth: number; + payload?: { value?: string | number }; + x?: number | string; + y?: number | string; +}) { + const datum = dataById.get(String(payload?.value ?? "")); + const resolvedX = typeof x === "number" ? x : Number(x ?? 0); + const resolvedY = typeof y === "number" ? y : Number(y ?? 0); + + if (!datum) { + return null; + } + + const isHighlighted = activeId != null && datum.id === activeId; + const hasExternalHighlight = activeId != null; + const contentOpacity = hasExternalHighlight && !isHighlighted ? 0.28 : 1; + const avatarBorderColor = isHighlighted + ? "color-mix(in srgb, var(--dashboardy-heading) 18%, var(--border))" + : undefined; + + return ( + + +
+
+
+ {datum.imageUrl ? ( + {datum.fullLabel} + ) : ( + + {getAvatarInitials(datum.fullLabel)} + + )} +
+ + {datum.axisLabel} + +
+
+
+
+ ); +} + +export function DashboardPerformanceChart({ + activeId, + data, +}: DashboardPerformanceChartProps) { + const chartData = useMemo( + () => + data.map((entry) => ({ + ...entry, + committed: entry.commits, + stub: entry.sessions > 0 ? 0 : ZERO_BAR_STUB_VALUE, + uncommitted: Math.max(entry.sessions - entry.commits, 0), + })), + [data], + ); + const dataById = useMemo( + () => new Map(chartData.map((entry) => [entry.id, entry] as const)), + [chartData], + ); + const resolvedActiveId = + activeId != null && dataById.has(activeId) ? activeId : null; + const axisMax = useMemo(() => getAxisMax(chartData), [chartData]); + const axisTicks = useMemo(() => getAxisTicks(axisMax), [axisMax]); + const barSize = useMemo( + () => getDashboardBarSize(chartData.length), + [chartData.length], + ); + const labelWidth = useMemo( + () => getDashboardBarLabelWidth(chartData.length), + [chartData.length], + ); + + return ( +
+ + + ( + + )} + tickLine={false} + /> + + } + /> + + } + /> + + } + /> + + } + /> + + +
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardPerformancePanel.tsx b/apps/web/src/features/dashboard/components/DashboardPerformancePanel.tsx new file mode 100644 index 00000000..294a0955 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardPerformancePanel.tsx @@ -0,0 +1,34 @@ +import type { UserDailyTrendData } from "@rudel/api-routes"; +import { DashboardDailySnapshotSection } from "@/features/dashboard/components/DashboardDailySnapshotSection"; +import { DashboardDeveloperPanel } from "@/features/dashboard/components/DashboardDeveloperPanel"; +import type { DashboardPerformanceUserComparison } from "@/features/dashboard/data/dashboard-performance-adapter"; +import type { DashboardOutputSnapshot } from "@/features/dashboard/data/dashboard-static-data"; + +export function DashboardPerformancePanel({ + isChartPending, + isSnapshotPending = false, + performanceUserDailyTrend, + performanceUsers, + snapshot, +}: { + isChartPending: boolean; + isSnapshotPending?: boolean; + performanceUserDailyTrend: UserDailyTrendData[] | undefined; + performanceUsers: DashboardPerformanceUserComparison[]; + snapshot: DashboardOutputSnapshot; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardPerformanceRosterTable.tsx b/apps/web/src/features/dashboard/components/DashboardPerformanceRosterTable.tsx new file mode 100644 index 00000000..ebce5eac --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardPerformanceRosterTable.tsx @@ -0,0 +1,360 @@ +import type { UserDailyTrendData } from "@rudel/api-routes"; +import { + DashboardCellStack, + DashboardGridTable, + DashboardInlineOverflowList, +} from "@/features/dashboard/components/DashboardGridTable"; +import { DashboardModelBadges } from "@/features/dashboard/components/DashboardModelBadges"; +import type { DashboardPerformanceUserComparison } from "@/features/dashboard/data/dashboard-performance-adapter"; +import { formatCompactNumber } from "@/lib/format"; + +type DashboardPerformanceRosterRow = { + activeRepositoryCount: number; + commitRate: number; + commits: number; + id: string; + imageUrl?: string | null; + modelsUsed: string[]; + repositoriesTouched: string[]; + sessions: number; + userLabel: string; +}; + +type DashboardPerformanceRosterVariant = "commits" | "repositories"; + +const MAX_VISIBLE_REPOSITORIES = 2; + +function getAvatarInitials(fullLabel: string) { + const fallbackToken = fullLabel.includes("@") + ? (fullLabel.split("@")[0] ?? fullLabel) + : fullLabel; + const parts = fallbackToken.split(/\s+/).filter(Boolean); + + if (parts.length === 0) { + return "AI"; + } + + if (parts.length === 1) { + return parts[0]?.slice(0, 2).toUpperCase() ?? "AI"; + } + + return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase(); +} + +function getRateTone(commitRate: number) { + if (commitRate >= 65) { + return { + dotClassName: "bg-[color:var(--dashboardy-success-foreground)]", + textClassName: "text-[color:var(--dashboardy-success-foreground)]", + }; + } + + if (commitRate >= 45) { + return { + dotClassName: "bg-[color:var(--dashboardy-warning-foreground)]", + textClassName: "text-[color:var(--dashboardy-warning-foreground)]", + }; + } + + return { + dotClassName: "bg-[color:var(--dashboardy-danger-foreground)]", + textClassName: "text-[color:var(--dashboardy-danger-foreground)]", + }; +} + +function buildRosterRows( + performanceUsers: DashboardPerformanceUserComparison[], + highlightedDate: string | null, + trendData: UserDailyTrendData[] | undefined, + variant: DashboardPerformanceRosterVariant, +): DashboardPerformanceRosterRow[] { + const rowMap = new Map( + (trendData ?? []).map( + (row) => [`${row.user_id}:${row.date}`, row] as const, + ), + ); + + return performanceUsers + .map((user) => { + const highlightedRow = + highlightedDate != null + ? rowMap.get(`${user.userId}:${highlightedDate}`) + : undefined; + const repositoriesTouched = + highlightedDate != null + ? [...(highlightedRow?.repositories_touched ?? [])] + : user.repositoriesTouched; + const sessions = + highlightedDate != null + ? (highlightedRow?.sessions ?? 0) + : user.sessions; + const commits = + highlightedDate != null + ? (highlightedRow?.total_commits ?? 0) + : user.commits; + const commitRate = + sessions > 0 ? Math.round((commits / sessions) * 100) : 0; + + return { + activeRepositoryCount: repositoriesTouched.length, + commitRate, + commits, + id: user.userId, + imageUrl: user.imageUrl, + modelsUsed: user.modelsUsed, + repositoriesTouched, + sessions, + userLabel: user.label, + }; + }) + .sort((left, right) => { + if (variant === "repositories") { + return ( + right.activeRepositoryCount - left.activeRepositoryCount || + right.sessions - left.sessions || + left.userLabel.localeCompare(right.userLabel) + ); + } + + return ( + right.commits - left.commits || + right.sessions - left.sessions || + left.userLabel.localeCompare(right.userLabel) + ); + }); +} + +export function DashboardPerformanceRosterTable({ + highlightedDate, + onHighlightUserChange, + performanceUsers, + trendData, + variant = "commits", +}: { + highlightedDate: string | null; + onHighlightUserChange?: (userId: string | null) => void; + performanceUsers: DashboardPerformanceUserComparison[]; + trendData: UserDailyTrendData[] | undefined; + variant?: DashboardPerformanceRosterVariant; +}) { + const rows = buildRosterRows( + performanceUsers, + highlightedDate, + trendData, + variant, + ); + const columns = + variant === "repositories" + ? [ + { + id: "user", + header: "User", + renderCell: (row: DashboardPerformanceRosterRow) => ( +
+
+ {row.imageUrl ? ( + {row.userLabel} + ) : ( + + {getAvatarInitials(row.userLabel)} + + )} +
+

+ {row.userLabel} +

+
+ ), + }, + { + id: "repositories", + header: "Repositories", + renderCell: (row: DashboardPerformanceRosterRow) => + row.repositoriesTouched.length > 0 ? ( +

+ +

+ ) : ( + + — + + ), + }, + { + id: "models", + header: "Models used", + renderCell: (row: DashboardPerformanceRosterRow) => ( +
+ +
+ ), + }, + { + id: "sessions", + header: "Sessions", + renderCell: (row: DashboardPerformanceRosterRow) => ( +

+ {row.sessions} +

+ ), + }, + { + id: "active-repos", + header: "Active repos", + renderCell: (row: DashboardPerformanceRosterRow) => ( +

+ {row.activeRepositoryCount} +

+ ), + }, + { + id: "avg-per-repo", + header: "Avg / repo", + renderCell: (row: DashboardPerformanceRosterRow) => ( + 0 + ? formatCompactNumber( + Math.round(row.sessions / row.activeRepositoryCount), + ) + : "—" + } + secondary={ + row.repositoriesTouched[0] + ? `Top: ${row.repositoriesTouched[0]}` + : "—" + } + primaryClassName="font-medium tabular-nums" + secondaryClassName="truncate" + /> + ), + }, + ] + : [ + { + id: "user", + header: "User", + renderCell: (row: DashboardPerformanceRosterRow) => ( +
+
+ {row.imageUrl ? ( + {row.userLabel} + ) : ( + + {getAvatarInitials(row.userLabel)} + + )} +
+

+ {row.userLabel} +

+
+ ), + }, + { + id: "repositories", + header: "Repositories", + renderCell: (row: DashboardPerformanceRosterRow) => + row.repositoriesTouched.length > 0 ? ( +

+ +

+ ) : ( + + — + + ), + }, + { + id: "models", + header: "Models used", + renderCell: (row: DashboardPerformanceRosterRow) => ( +
+ +
+ ), + }, + { + id: "sessions", + header: "Sessions", + renderCell: (row: DashboardPerformanceRosterRow) => ( +

+ {row.sessions} +

+ ), + }, + { + id: "commits", + header: "Commits", + renderCell: (row: DashboardPerformanceRosterRow) => ( +

+ {row.commits} +

+ ), + }, + { + id: "rate", + header: "Rate", + renderCell: (row: DashboardPerformanceRosterRow) => { + const rateTone = getRateTone(row.commitRate); + + return ( +
+ +

+ {row.commitRate}% +

+
+ ); + }, + }, + ]; + + return ( + row.id} + gridTemplateColumns={ + variant === "repositories" + ? "minmax(180px,12fr) minmax(190px,9fr) minmax(180px,8fr) 90px 90px minmax(140px,7fr)" + : "minmax(180px,12fr) minmax(168px,8fr) minmax(180px,8fr) 80px 80px 112px" + } + minWidthClassName={ + variant === "repositories" ? "min-w-[66rem]" : "min-w-[60rem]" + } + onRowHoverChange={onHighlightUserChange} + getHoverRowId={(row) => row.id} + /> + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardPerformanceTrendChart.tsx b/apps/web/src/features/dashboard/components/DashboardPerformanceTrendChart.tsx new file mode 100644 index 00000000..67ee44ee --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardPerformanceTrendChart.tsx @@ -0,0 +1,567 @@ +"use client"; + +import type { UserDailyTrendData } from "@rudel/api-routes"; +import { format, parseISO } from "date-fns"; +import { useMemo } from "react"; +import { + Area, + CartesianGrid, + Line, + LineChart, + ReferenceDot, + XAxis, + YAxis, +} from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart"; +import { ToggleGroup, ToggleGroupItem } from "@/app/ui/toggle-group"; +import { + type DashboardPerformanceTrendMetric, + type DashboardPerformanceTrendSeries, + getDashboardPerformanceTrendValue, +} from "@/features/dashboard/data/dashboard-performance-trend"; +import { formatCompactWholeNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +type TrendChartRow = { + date: string; + fullLabel: string; +} & Record; + +function formatMetricValue( + metric: DashboardPerformanceTrendMetric, + value: number, +) { + if (metric === "tokens") { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}K`; + } + } + + return value.toLocaleString(); +} + +function formatMetricAxisValue( + metric: DashboardPerformanceTrendMetric, + value: number, +) { + if (metric === "tokens") { + return formatCompactWholeNumber(value); + } + + return Math.round(value).toLocaleString(); +} + +function getTrendAreaOpacity( + hasVisibleHighlightedSeries: boolean, + isHighlighted: boolean, +) { + if (!hasVisibleHighlightedSeries) { + return 0.08; + } + + return isHighlighted ? 0.22 : 0.03; +} + +function getTickLabel(dateValue: string, index: number, total: number) { + const parsedDate = parseISO(dateValue); + + if (Number.isNaN(parsedDate.getTime())) { + return ""; + } + + if (total <= 7) { + return format(parsedDate, "EEE d"); + } + + const interval = Math.max(1, Math.ceil(total / 5)); + const isBoundaryTick = index === 0 || index === total - 1; + + if (!isBoundaryTick && index % interval !== 0) { + return ""; + } + + return format(parsedDate, "MMM d"); +} + +function buildFullLabel(dateValue: string) { + const parsedDate = parseISO(dateValue); + + if (Number.isNaN(parsedDate.getTime())) { + return dateValue; + } + + return format(parsedDate, "EEEE, MMM d"); +} + +function PerformanceTrendTooltip({ + active, + metric, + payload, +}: { + active?: boolean; + metric: DashboardPerformanceTrendMetric; + payload?: Array<{ + color?: string; + dataKey?: string; + name?: string; + value?: number | string; + payload?: TrendChartRow; + }>; +}) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + + if (!point) { + return null; + } + + const rankedPayload = [...payload] + .filter((item) => { + const numericValue = + typeof item.value === "number" + ? item.value + : Number(item.value ?? Number.NaN); + return Number.isFinite(numericValue) && numericValue > 0; + }) + .sort((left, right) => Number(right.value ?? 0) - Number(left.value ?? 0)); + + return ( +
+
+

{point.fullLabel}

+

+ {metric === "commits" + ? "Commits" + : metric === "tokens" + ? "Tokens" + : metric === "repositories" + ? "Repos" + : "Sessions"} +

+
+
+ {rankedPayload.length > 0 ? ( + rankedPayload.map((item) => ( +
+
+
+ + {typeof item.value === "number" + ? formatMetricValue(metric, item.value) + : item.value} + +
+ )) + ) : ( +

No visible activity

+ )} +
+
+ ); +} + +export function DashboardPerformanceTrendChart({ + className, + highlightedSeriesId, + hiddenSeriesIds, + metric, + onHighlightDateChange, + onMetricChange, + onToggleSeries, + trendData, + trendSeries, + availableMetrics = ["sessions", "commits"], +}: { + availableMetrics?: DashboardPerformanceTrendMetric[]; + className?: string; + highlightedSeriesId?: string | null; + hiddenSeriesIds: string[]; + metric: DashboardPerformanceTrendMetric; + onHighlightDateChange?: (date: string | null) => void; + onMetricChange: (metric: DashboardPerformanceTrendMetric) => void; + onToggleSeries: (userId: string) => void; + trendData: UserDailyTrendData[] | undefined; + trendSeries: DashboardPerformanceTrendSeries[]; +}) { + const hiddenSeriesSet = useMemo( + () => new Set(hiddenSeriesIds), + [hiddenSeriesIds], + ); + const hasVisibleHighlightedSeries = useMemo( + () => + highlightedSeriesId != null && + trendSeries.some( + (series) => + series.userId === highlightedSeriesId && + !hiddenSeriesSet.has(series.userId), + ), + [hiddenSeriesSet, highlightedSeriesId, trendSeries], + ); + const { + allSeries, + axisMax, + chartConfig, + chartData, + seriesTotals, + visibleSeries, + } = useMemo(() => { + const rows = trendData ?? []; + + if (rows.length === 0) { + return { + allSeries: [] as DashboardPerformanceTrendSeries[], + axisMax: 1, + chartConfig: {} satisfies ChartConfig, + chartData: [] as TrendChartRow[], + seriesTotals: {} as Record, + visibleSeries: [] as DashboardPerformanceTrendSeries[], + }; + } + + const allDates = Array.from(new Set(rows.map((row) => row.date))).sort(); + const rowMap = new Map( + rows.map((row) => [`${row.user_id}:${row.date}`, row] as const), + ); + const allSeries = trendSeries; + const seriesTotals = Object.fromEntries( + allSeries.map((series) => [series.userId, 0]), + ) as Record; + + for (const row of rows) { + seriesTotals[row.user_id] = + (seriesTotals[row.user_id] ?? 0) + + getDashboardPerformanceTrendValue(row, metric); + } + + const visibleSeries = allSeries.filter( + (series) => !hiddenSeriesSet.has(series.userId), + ); + const chartData = allDates.map((date) => { + const nextRow: TrendChartRow = { + date, + fullLabel: buildFullLabel(date), + }; + + for (const series of allSeries) { + nextRow[series.userId] = getDashboardPerformanceTrendValue( + rowMap.get(`${series.userId}:${date}`), + metric, + ); + } + + return nextRow; + }); + const chartConfig = Object.fromEntries( + allSeries.map((series) => [ + series.userId, + { + color: series.color, + label: series.label, + }, + ]), + ) satisfies ChartConfig; + const axisMax = Math.max( + 1, + ...chartData.flatMap((row) => + visibleSeries.map((series) => Number(row[series.userId] ?? 0)), + ), + ); + + return { + allSeries, + axisMax, + chartConfig, + chartData, + seriesTotals, + visibleSeries, + }; + }, [hiddenSeriesSet, metric, trendData, trendSeries]); + const orderedVisibleSeries = useMemo(() => { + if ( + highlightedSeriesId == null || + !visibleSeries.some((series) => series.userId === highlightedSeriesId) + ) { + return visibleSeries; + } + + return [ + ...visibleSeries.filter( + (series) => series.userId !== highlightedSeriesId, + ), + ...visibleSeries.filter( + (series) => series.userId === highlightedSeriesId, + ), + ]; + }, [highlightedSeriesId, visibleSeries]); + + if (allSeries.length === 0) { + return ( +
+ No developer activity in the selected range. +
+ ); + } + + return ( +
+
+ { + const nextMetric = nextValue[0]; + + if ( + nextMetric === "sessions" || + nextMetric === "commits" || + nextMetric === "tokens" + ) { + onMetricChange(nextMetric); + } + }} + > + {availableMetrics.includes("sessions") ? ( + + Sessions + + ) : null} + {availableMetrics.includes("commits") ? ( + + Commits + + ) : null} + {availableMetrics.includes("tokens") ? ( + + Tokens + + ) : null} + +
+ {allSeries.map((series) => { + const isHidden = hiddenSeriesSet.has(series.userId); + const isHighlighted = highlightedSeriesId === series.userId; + const total = seriesTotals[series.userId] ?? 0; + + return ( + + ); + })} +
+
+ +
+ {visibleSeries.length === 0 ? ( +
+ Select at least one developer. +
+ ) : ( + + onHighlightDateChange?.(null)} + onMouseMove={(state: { activeLabel?: unknown }) => { + onHighlightDateChange?.( + typeof state.activeLabel === "string" + ? state.activeLabel + : null, + ); + }} + > + + + getTickLabel(String(value), index, chartData.length) + } + tickLine={false} + tickMargin={8} + tick={{ + fontSize: 12, + fontWeight: 500, + fill: "var(--dashboardy-muted)", + opacity: 0.65, + }} + /> + + formatMetricAxisValue(metric, Number(value)) + } + tickLine={false} + tickMargin={8} + width={34} + tick={{ + fontSize: 12, + fontWeight: 500, + fill: "var(--dashboardy-muted)", + opacity: 0.65, + }} + /> + } + /> + {orderedVisibleSeries.map((series) => { + const isHighlighted = highlightedSeriesId === series.userId; + + return ( + + ); + })} + {orderedVisibleSeries.map((series) => ( + + ))} + {(() => { + const lastRow = chartData.at(-1); + + if (!lastRow) { + return null; + } + + return orderedVisibleSeries.map((series) => ( + + )); + })()} + + + )} +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryChart.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryChart.tsx new file mode 100644 index 00000000..d791d616 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardRepositoryChart.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { useMemo } from "react"; +import { Bar, BarChart, XAxis, YAxis } from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart"; +import { DashboardStackedTopRoundedBar } from "@/features/dashboard/components/DashboardStackedTopRoundedBar"; +import { + getDashboardBarLabelWidth, + getDashboardBarSize, +} from "@/features/dashboard/components/dashboard-bar-chart-layout"; + +const ZERO_BAR_STUB_VALUE = 0.75; +const COMMIT_CHART_COLOR = "#1949A9"; +const UNCOMMITTED_CHART_COLOR = "#C21674"; +const SESSION_CHART_COLOR = "#159C89"; +type DashboardRepositoryChartVariant = "commits" | "sessions"; + +export type DashboardRepositoryChartDatum = { + activeDays?: number | null; + axisLabel: string; + commits: number; + fullLabel: string; + id: string; + sessions: number; +}; + +type DashboardRepositoryChartRow = DashboardRepositoryChartDatum & { + committed: number; + stub: number; + uncommitted: number; +}; + +function getRepositoryAxisLabel(label: string) { + if (label.length <= 14) { + return label; + } + + return `${label.slice(0, 12)}…`; +} + +function getAxisMax(data: DashboardRepositoryChartRow[]) { + const maxSessions = Math.max(...data.map((point) => point.sessions), 0); + + if (maxSessions <= 0) { + return 15; + } + + if (maxSessions <= 30) { + return Math.ceil(maxSessions / 5) * 5; + } + + if (maxSessions <= 90) { + return Math.ceil(maxSessions / 10) * 10; + } + + return Math.ceil(maxSessions / 20) * 20; +} + +function getAxisTicks(axisMax: number) { + const step = + axisMax <= 30 ? 5 : axisMax <= 90 ? 10 : axisMax <= 180 ? 20 : 40; + const ticks = Array.from( + { length: Math.floor(axisMax / step) + 1 }, + (_, index) => index * step, + ); + + return ticks.length > 1 ? ticks : [0, axisMax]; +} + +function DashboardRepositoryTooltip({ + active, + variant, + payload, +}: { + active?: boolean; + variant: DashboardRepositoryChartVariant; + payload?: Array<{ payload: DashboardRepositoryChartRow }>; +}) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + + if (!point) { + return null; + } + + const commitRate = + point.sessions > 0 + ? Math.round((point.committed / point.sessions) * 100) + : 0; + + return ( +
+
{point.fullLabel}
+
+
+ Sessions + + {point.sessions} + +
+ {variant === "commits" ? ( + <> +
+ Commit rate + + {commitRate}% + +
+
+ Committed sessions + + {point.committed} + +
+
+ Uncommitted sessions + + {point.uncommitted} + +
+ + ) : ( + <> +
+ Active days + + {point.activeDays ?? "—"} + +
+
+ Avg / day + + {point.activeDays && point.activeDays > 0 + ? Math.round( + point.sessions / point.activeDays, + ).toLocaleString() + : "—"} + +
+ + )} +
+
+ ); +} + +function DashboardRepositoryAxisTick({ + activeId, + dataById, + labelWidth, + payload, + x = 0, + y = 0, +}: { + activeId?: string | null; + dataById: Map; + labelWidth: number; + payload?: { value?: string | number }; + x?: number | string; + y?: number | string; +}) { + const datum = dataById.get(String(payload?.value ?? "")); + const resolvedX = typeof x === "number" ? x : Number(x ?? 0); + const resolvedY = typeof y === "number" ? y : Number(y ?? 0); + + if (!datum) { + return null; + } + + const isHighlighted = activeId != null && datum.id === activeId; + const hasExternalHighlight = activeId != null; + const contentOpacity = hasExternalHighlight && !isHighlighted ? 0.28 : 1; + + return ( + + +
+ + {datum.axisLabel} + +
+
+
+ ); +} + +export function DashboardRepositoryChart({ + activeId, + data, + variant = "commits", +}: { + activeId?: string | null; + data: DashboardRepositoryChartDatum[]; + variant?: DashboardRepositoryChartVariant; +}) { + const chartData = useMemo( + () => + data.map((entry) => ({ + ...entry, + committed: variant === "sessions" ? entry.sessions : entry.commits, + stub: entry.sessions > 0 ? 0 : ZERO_BAR_STUB_VALUE, + uncommitted: + variant === "sessions" + ? 0 + : Math.max(entry.sessions - entry.commits, 0), + })), + [data, variant], + ); + const dataById = useMemo( + () => new Map(chartData.map((entry) => [entry.id, entry] as const)), + [chartData], + ); + const resolvedActiveId = + activeId != null && dataById.has(activeId) ? activeId : null; + const axisMax = useMemo(() => getAxisMax(chartData), [chartData]); + const axisTicks = useMemo(() => getAxisTicks(axisMax), [axisMax]); + const barSize = useMemo( + () => getDashboardBarSize(chartData.length), + [chartData.length], + ); + const labelWidth = useMemo( + () => getDashboardBarLabelWidth(chartData.length, "repository"), + [chartData.length], + ); + const chartConfig = useMemo( + () => + ({ + committed: { + label: + variant === "sessions" + ? "Repository sessions" + : "Committed sessions", + color: + variant === "sessions" ? SESSION_CHART_COLOR : COMMIT_CHART_COLOR, + }, + uncommitted: { + label: + variant === "sessions" + ? "Repository sessions" + : "Uncommitted sessions", + color: + variant === "sessions" + ? SESSION_CHART_COLOR + : UNCOMMITTED_CHART_COLOR, + }, + }) satisfies ChartConfig, + [variant], + ); + + return ( +
+ + + ( + + )} + tickLine={false} + /> + + } + /> + + } + /> + + } + /> + + } + /> + + +
+ ); +} + +export function buildDashboardRepositoryChartData( + rows: Array<{ + activeDays?: number | null; + id: string; + label: string; + commits: number; + sessions: number; + }>, +): DashboardRepositoryChartDatum[] { + return rows.map((row) => ({ + activeDays: row.activeDays, + axisLabel: getRepositoryAxisLabel(row.label), + commits: row.commits, + fullLabel: row.label, + id: row.id, + sessions: row.sessions, + })); +} diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx new file mode 100644 index 00000000..aecd8aa7 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx @@ -0,0 +1,204 @@ +import type { RepositoryDailyTrendData } from "@rudel/api-routes"; +import { FolderGit2Icon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Skeleton } from "@/app/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/app/ui/toggle-group"; +import { DashboardAnalysisPanel } from "@/features/dashboard/components/DashboardAnalysisPanel"; +import { + buildDashboardRepositoryChartData, + DashboardRepositoryChart, +} from "@/features/dashboard/components/DashboardRepositoryChart"; +import { DashboardRepositoryTable } from "@/features/dashboard/components/DashboardRepositoryTable"; +import { DashboardRepositoryTrendChart } from "@/features/dashboard/components/DashboardRepositoryTrendChart"; +import { + buildDashboardRepositorySummaryRows, + buildDashboardRepositoryTrendSeries, + type DashboardRepositoryTrendMetric, +} from "@/features/dashboard/data/dashboard-repository-trend"; +import type { DashboardRankedOutputRow } from "@/features/dashboard/data/dashboard-static-data"; + +type RepositoryChartView = "total" | "over-time"; +type DashboardRepositoryPanelVariant = "commits" | "sessions"; +const MAX_VISIBLE_REPOSITORY_SERIES = 7; +const MAX_VISIBLE_REPOSITORY_BARS = 20; + +const CHART_FALLBACK_BAR_KEYS = [ + "repository-chart-bar-1", + "repository-chart-bar-2", + "repository-chart-bar-3", + "repository-chart-bar-4", + "repository-chart-bar-5", +] as const; + +const CHART_FALLBACK_LABEL_KEYS = [ + "repository-chart-label-1", + "repository-chart-label-2", + "repository-chart-label-3", + "repository-chart-label-4", + "repository-chart-label-5", +] as const; + +function DashboardRepositoryChartFallback() { + return ( +
+
+ {CHART_FALLBACK_BAR_KEYS.map((key) => ( + + ))} +
+
+ {CHART_FALLBACK_LABEL_KEYS.map((key) => ( + + ))} +
+
+ ); +} + +export function DashboardRepositoryPanel({ + isChartPending, + repositories, + repositoryDailyTrend, + variant = "commits", +}: { + isChartPending: boolean; + repositories: DashboardRankedOutputRow[]; + repositoryDailyTrend: RepositoryDailyTrendData[] | undefined; + variant?: DashboardRepositoryPanelVariant; +}) { + const [chartView, setChartView] = useState("total"); + const [hiddenTrendSeriesIds, setHiddenTrendSeriesIds] = useState( + [], + ); + const [highlightedRepositoryId, setHighlightedRepositoryId] = useState< + string | null + >(null); + const [trendMetric, setTrendMetric] = + useState("sessions"); + const repositoryRows = useMemo( + () => + buildDashboardRepositorySummaryRows( + repositories, + repositoryDailyTrend, + variant === "sessions" ? "sessions" : "commits", + ), + [repositories, repositoryDailyTrend, variant], + ); + const visibleChartRows = useMemo( + () => repositoryRows.slice(0, MAX_VISIBLE_REPOSITORY_SERIES), + [repositoryRows], + ); + const hiddenChartRows = useMemo( + () => repositoryRows.slice(MAX_VISIBLE_REPOSITORY_SERIES), + [repositoryRows], + ); + const chartData = useMemo( + () => + buildDashboardRepositoryChartData( + repositoryRows.slice(0, MAX_VISIBLE_REPOSITORY_BARS), + ), + [repositoryRows], + ); + + const hasChartData = repositoryRows.length > 0; + const hasTrendData = useMemo( + () => (repositoryDailyTrend?.length ?? 0) > 0, + [repositoryDailyTrend], + ); + const trendSeries = useMemo( + () => + buildDashboardRepositoryTrendSeries( + visibleChartRows, + repositoryDailyTrend, + trendMetric, + ), + [repositoryDailyTrend, trendMetric, visibleChartRows], + ); + + function handleToggleTrendSeries(repositoryId: string) { + setHiddenTrendSeriesIds((currentIds) => + currentIds.includes(repositoryId) + ? currentIds.filter((id) => id !== repositoryId) + : [...currentIds, repositoryId], + ); + } + + return ( + + } + chartShellDataSlot="dashboard-repository-chart-shell" + controls={ + { + const nextView = nextValue[0]; + + if (nextView === "total" || nextView === "over-time") { + setChartView(nextView); + } + }} + > + + Total + + + Over time + + + } + chartContent={ + isChartPending ? ( + + ) : chartView === "over-time" ? ( + hasTrendData ? ( + + ) : ( +
+ No repository activity in the selected range. +
+ ) + ) : hasChartData ? ( + + ) : ( +
+ No repository activity in the selected range. +
+ ) + } + tableContent={ + + } + /> + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx new file mode 100644 index 00000000..294d7801 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx @@ -0,0 +1,267 @@ +import type { RepositoryDailyTrendData } from "@rudel/api-routes"; +import { useState } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/app/ui/popover"; +import { + DashboardCellStack, + DashboardGridTable, + DashboardTableFooterNote, +} from "@/features/dashboard/components/DashboardGridTable"; +import type { DashboardRepositorySummaryRow } from "@/features/dashboard/data/dashboard-repository-trend"; +import { formatPercent } from "@/lib/format"; + +type DashboardRepositoryTableRow = DashboardRepositorySummaryRow; +const MAX_VISIBLE_REPOSITORIES = 7; +type DashboardRepositoryTableVariant = "commits" | "sessions"; + +function buildRepositoryRows( + rows: DashboardRepositorySummaryRow[], + highlightedDate: string | null, + trendData: RepositoryDailyTrendData[] | undefined, +): DashboardRepositoryTableRow[] { + const rowMap = new Map( + (trendData ?? []).map( + (row) => [`${row.repository}:${row.date}`, row] as const, + ), + ); + + return rows.map((row) => { + const highlightedRow = + highlightedDate != null + ? rowMap.get(`${row.id}:${highlightedDate}`) + : undefined; + const sessions = + highlightedDate != null ? (highlightedRow?.sessions ?? 0) : row.sessions; + const commits = + highlightedDate != null + ? (highlightedRow?.total_commits ?? 0) + : row.commits; + + return { + ...row, + commitRate: sessions > 0 ? Math.round((commits / sessions) * 100) : 0, + commits, + sessions, + }; + }); +} + +function getRateTone(commitRate: number) { + if (commitRate >= 65) { + return { + dotClassName: "bg-[color:var(--dashboardy-success-foreground)]", + textClassName: "text-[color:var(--dashboardy-success-foreground)]", + }; + } + + if (commitRate >= 45) { + return { + dotClassName: "bg-[color:var(--dashboardy-warning-foreground)]", + textClassName: "text-[color:var(--dashboardy-warning-foreground)]", + }; + } + + return { + dotClassName: "bg-[color:var(--dashboardy-danger-foreground)]", + textClassName: "text-[color:var(--dashboardy-danger-foreground)]", + }; +} + +function DashboardRepositoryOverflowPopover({ + rows, +}: { + rows: DashboardRepositorySummaryRow[]; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + ({rows.length} more) + + setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > +
+ {rows.map((row) => ( +

+ {row.label} +

+ ))} +
+
+
+ ); +} + +export function DashboardRepositoryTable({ + highlightedDate, + onHighlightRepositoryChange, + rows, + trendData, + variant = "commits", +}: { + highlightedDate: string | null; + onHighlightRepositoryChange?: (repositoryId: string | null) => void; + rows: DashboardRepositorySummaryRow[]; + trendData: RepositoryDailyTrendData[] | undefined; + variant?: DashboardRepositoryTableVariant; +}) { + const displayRows = buildRepositoryRows(rows, highlightedDate, trendData); + const visibleRows = displayRows.slice(0, MAX_VISIBLE_REPOSITORIES); + const hiddenRows = displayRows.slice(MAX_VISIBLE_REPOSITORIES); + const hiddenRowCount = Math.max(0, displayRows.length - visibleRows.length); + const totalSessions = displayRows.reduce((sum, row) => sum + row.sessions, 0); + const columns = + variant === "sessions" + ? [ + { + id: "repository", + header: "Repository", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.label} +

+ ), + }, + { + id: "active-days", + header: "Active days", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.activeDays ?? "—"} +

+ ), + }, + { + id: "sessions", + header: "Sessions", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.sessions} +

+ ), + }, + { + id: "avg-day", + header: "Avg / day", + renderCell: (row: DashboardRepositoryTableRow) => ( + 0 + ? Math.round(row.sessions / row.activeDays).toLocaleString() + : "—" + } + secondary={ + row.activeDays && row.activeDays > 0 + ? `${row.activeDays} active days` + : "No trend data" + } + primaryClassName="font-medium tabular-nums" + /> + ), + }, + { + id: "share", + header: "Share", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {totalSessions > 0 + ? formatPercent((row.sessions / totalSessions) * 100) + : "0%"} +

+ ), + }, + ] + : [ + { + id: "repository", + header: "Repository", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.label} +

+ ), + }, + { + id: "active-days", + header: "Active days", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.activeDays ?? "—"} +

+ ), + }, + { + id: "sessions", + header: "Sessions", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.sessions} +

+ ), + }, + { + id: "commits", + header: "Commits", + renderCell: (row: DashboardRepositoryTableRow) => ( +

+ {row.commits} +

+ ), + }, + { + id: "rate", + header: "Rate", + renderCell: (row: DashboardRepositoryTableRow) => { + const rateTone = getRateTone(row.commitRate); + + return ( +
+ +

+ {row.commitRate}% +

+
+ ); + }, + }, + ]; + + return ( + row.id} + gridTemplateColumns={ + variant === "sessions" + ? "minmax(200px,14fr) 100px 90px minmax(128px,8fr) 100px" + : "minmax(200px,14fr) 100px 90px 90px 112px" + } + minWidthClassName={ + variant === "sessions" ? "min-w-[46rem]" : "min-w-[44rem]" + } + onRowHoverChange={onHighlightRepositoryChange} + getHoverRowId={(row) => row.id} + footer={ + hiddenRowCount > 0 ? ( + + + + ) : null + } + /> + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryTrendChart.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryTrendChart.tsx new file mode 100644 index 00000000..a17af5ed --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardRepositoryTrendChart.tsx @@ -0,0 +1,558 @@ +"use client"; + +import type { RepositoryDailyTrendData } from "@rudel/api-routes"; +import { format, parseISO } from "date-fns"; +import { useMemo } from "react"; +import { + Area, + CartesianGrid, + Line, + LineChart, + ReferenceDot, + XAxis, + YAxis, +} from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart"; +import { Popover, PopoverContent, PopoverTrigger } from "@/app/ui/popover"; +import { ToggleGroup, ToggleGroupItem } from "@/app/ui/toggle-group"; +import { + type DashboardRepositorySummaryRow, + type DashboardRepositoryTrendMetric, + type DashboardRepositoryTrendSeries, + getDashboardRepositoryTrendValue, +} from "@/features/dashboard/data/dashboard-repository-trend"; +import { cn } from "@/lib/utils"; + +type TrendChartRow = { + date: string; + fullLabel: string; +} & Record; + +function getTickLabel(dateValue: string, index: number, total: number) { + const parsedDate = parseISO(dateValue); + + if (Number.isNaN(parsedDate.getTime())) { + return ""; + } + + if (total <= 7) { + return format(parsedDate, "EEE d"); + } + + const interval = Math.max(1, Math.ceil(total / 5)); + const isBoundaryTick = index === 0 || index === total - 1; + + if (!isBoundaryTick && index % interval !== 0) { + return ""; + } + + return format(parsedDate, "MMM d"); +} + +function buildFullLabel(dateValue: string) { + const parsedDate = parseISO(dateValue); + + if (Number.isNaN(parsedDate.getTime())) { + return dateValue; + } + + return format(parsedDate, "EEEE, MMM d"); +} + +function getTrendAreaOpacity( + hasVisibleHighlightedSeries: boolean, + isHighlighted: boolean, +) { + if (!hasVisibleHighlightedSeries) { + return 0.08; + } + + return isHighlighted ? 0.22 : 0.03; +} + +function RepositoryTrendTooltip({ + active, + metric, + payload, +}: { + active?: boolean; + metric: DashboardRepositoryTrendMetric; + payload?: Array<{ + color?: string; + dataKey?: string; + name?: string; + value?: number | string; + payload?: TrendChartRow; + }>; +}) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + + if (!point) { + return null; + } + + const rankedPayload = [...payload] + .filter((item) => { + const numericValue = + typeof item.value === "number" + ? item.value + : Number(item.value ?? Number.NaN); + return Number.isFinite(numericValue) && numericValue > 0; + }) + .sort((left, right) => Number(right.value ?? 0) - Number(left.value ?? 0)); + + return ( +
+
+

{point.fullLabel}

+

+ {metric === "commits" ? "Commits" : "Sessions"} +

+
+
+ {rankedPayload.length > 0 ? ( + rankedPayload.map((item) => ( +
+
+
+ + {typeof item.value === "number" + ? item.value.toLocaleString() + : item.value} + +
+ )) + ) : ( +

No visible activity

+ )} +
+
+ ); +} + +function DashboardRepositoryTrendOverflowPopover({ + rows, +}: { + rows: DashboardRepositorySummaryRow[]; +}) { + return ( + + + ({rows.length} more) + + +
+ {rows.map((row) => ( +

+ {row.label} +

+ ))} +
+
+
+ ); +} + +export function DashboardRepositoryTrendChart({ + availableMetrics = ["sessions", "commits"], + className, + highlightedSeriesId, + hiddenRows, + hiddenSeriesIds, + metric, + onHighlightDateChange, + onMetricChange, + onToggleSeries, + trendData, + trendSeries, +}: { + availableMetrics?: DashboardRepositoryTrendMetric[]; + className?: string; + highlightedSeriesId?: string | null; + hiddenRows: DashboardRepositorySummaryRow[]; + hiddenSeriesIds: string[]; + metric: DashboardRepositoryTrendMetric; + onHighlightDateChange?: (date: string | null) => void; + onMetricChange: (metric: DashboardRepositoryTrendMetric) => void; + onToggleSeries: (repositoryId: string) => void; + trendData: RepositoryDailyTrendData[] | undefined; + trendSeries: DashboardRepositoryTrendSeries[]; +}) { + const hiddenSeriesSet = useMemo( + () => new Set(hiddenSeriesIds), + [hiddenSeriesIds], + ); + const hasVisibleHighlightedSeries = useMemo( + () => + highlightedSeriesId != null && + trendSeries.some( + (series) => + series.repositoryId === highlightedSeriesId && + !hiddenSeriesSet.has(series.repositoryId), + ), + [hiddenSeriesSet, highlightedSeriesId, trendSeries], + ); + const { + allSeries, + axisMax, + chartConfig, + chartData, + seriesTotals, + visibleSeries, + } = useMemo(() => { + const rows = trendData ?? []; + + if (rows.length === 0) { + return { + allSeries: [] as DashboardRepositoryTrendSeries[], + axisMax: 1, + chartConfig: {} satisfies ChartConfig, + chartData: [] as TrendChartRow[], + seriesTotals: {} as Record, + visibleSeries: [] as DashboardRepositoryTrendSeries[], + }; + } + + const allDates = Array.from(new Set(rows.map((row) => row.date))).sort(); + const rowMap = new Map( + rows.map((row) => [`${row.repository}:${row.date}`, row] as const), + ); + const allSeries = trendSeries; + const seriesTotals = Object.fromEntries( + allSeries.map((series) => [series.repositoryId, 0]), + ) as Record; + + for (const row of rows) { + seriesTotals[row.repository] = + (seriesTotals[row.repository] ?? 0) + + getDashboardRepositoryTrendValue(row, metric); + } + + const visibleSeries = allSeries.filter( + (series) => !hiddenSeriesSet.has(series.repositoryId), + ); + const chartData = allDates.map((date) => { + const nextRow: TrendChartRow = { + date, + fullLabel: buildFullLabel(date), + }; + + for (const series of allSeries) { + nextRow[series.repositoryId] = getDashboardRepositoryTrendValue( + rowMap.get(`${series.repositoryId}:${date}`), + metric, + ); + } + + return nextRow; + }); + const chartConfig = Object.fromEntries( + allSeries.map((series) => [ + series.repositoryId, + { + color: series.color, + label: series.label, + }, + ]), + ) satisfies ChartConfig; + const axisMax = Math.max( + 1, + ...chartData.flatMap((row) => + visibleSeries.map((series) => Number(row[series.repositoryId] ?? 0)), + ), + ); + + return { + allSeries, + axisMax, + chartConfig, + chartData, + seriesTotals, + visibleSeries, + }; + }, [hiddenSeriesSet, metric, trendData, trendSeries]); + const orderedVisibleSeries = useMemo(() => { + if ( + highlightedSeriesId == null || + !visibleSeries.some( + (series) => series.repositoryId === highlightedSeriesId, + ) + ) { + return visibleSeries; + } + + return [ + ...visibleSeries.filter( + (series) => series.repositoryId !== highlightedSeriesId, + ), + ...visibleSeries.filter( + (series) => series.repositoryId === highlightedSeriesId, + ), + ]; + }, [highlightedSeriesId, visibleSeries]); + + if (allSeries.length === 0) { + return ( +
+ No repository activity in the selected range. +
+ ); + } + + return ( +
+
+ { + const nextMetric = nextValue[0]; + + if (nextMetric === "sessions" || nextMetric === "commits") { + onMetricChange(nextMetric); + } + }} + > + {availableMetrics.includes("sessions") ? ( + + Sessions + + ) : null} + {availableMetrics.includes("commits") ? ( + + Commits + + ) : null} + +
+ {allSeries.map((series) => { + const isHidden = hiddenSeriesSet.has(series.repositoryId); + const isHighlighted = highlightedSeriesId === series.repositoryId; + const total = seriesTotals[series.repositoryId] ?? 0; + + return ( + + ); + })} + {hiddenRows.length > 0 ? ( + + ) : null} +
+
+ +
+ {visibleSeries.length === 0 ? ( +
+ Select at least one repository. +
+ ) : ( + + onHighlightDateChange?.(null)} + onMouseMove={(state: { activeLabel?: unknown }) => { + onHighlightDateChange?.( + typeof state.activeLabel === "string" + ? state.activeLabel + : null, + ); + }} + > + + + getTickLabel(String(value), index, chartData.length) + } + tickLine={false} + tickMargin={8} + tick={{ + fontSize: 12, + fontWeight: 500, + fill: "var(--dashboardy-muted)", + opacity: 0.65, + }} + /> + + } + /> + {orderedVisibleSeries.map((series) => { + const isHighlighted = + highlightedSeriesId === series.repositoryId; + + return ( + + ); + })} + {orderedVisibleSeries.map((series) => ( + + ))} + {(() => { + const lastRow = chartData.at(-1); + + if (!lastRow) { + return null; + } + + return orderedVisibleSeries.map((series) => ( + + )); + })()} + + + )} +
+
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardStackedTopRoundedBar.tsx b/apps/web/src/features/dashboard/components/DashboardStackedTopRoundedBar.tsx new file mode 100644 index 00000000..0cb018ea --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardStackedTopRoundedBar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Rectangle } from "recharts"; + +type SeriesKey = "committed" | "stub" | "uncommitted"; + +type StackedBarPayload = { + committed: number; + id?: string; + stub?: number; + uncommitted: number; +}; + +export function DashboardStackedTopRoundedBar({ + activeId, + dataKey, + fill, + height, + payload, + radius = 4, + width, + x, + y, +}: { + activeId?: string | null; + dataKey?: SeriesKey; + fill?: string; + height?: number; + payload?: StackedBarPayload; + radius?: number; + width?: number; + x?: number; + y?: number; +}) { + if ( + typeof x !== "number" || + typeof y !== "number" || + typeof width !== "number" || + typeof height !== "number" || + !payload || + !dataKey || + height <= 0 + ) { + return null; + } + + const isTopSegment = + dataKey === "stub" + ? (payload.stub ?? 0) > 0 + : dataKey === "uncommitted" + ? payload.uncommitted > 0 + : payload.committed > 0 && + payload.uncommitted <= 0 && + (payload.stub ?? 0) <= 0; + const isHighlighted = activeId != null && payload.id === activeId; + const hasExternalHighlight = activeId != null; + const highlightStroke = + "color-mix(in srgb, var(--dashboardy-heading) 22%, transparent)"; + const barOpacity = hasExternalHighlight && !isHighlighted ? 0.16 : 1; + const showStroke = isHighlighted && isTopSegment; + + return ( + + ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardTokenDeveloperChart.tsx b/apps/web/src/features/dashboard/components/DashboardTokenDeveloperChart.tsx new file mode 100644 index 00000000..28b31dc6 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardTokenDeveloperChart.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useMemo } from "react"; +import { Bar, BarChart, XAxis, YAxis } from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart"; +import { DashboardStackedTopRoundedBar } from "@/features/dashboard/components/DashboardStackedTopRoundedBar"; +import { + getDashboardBarLabelWidth, + getDashboardBarSize, +} from "@/features/dashboard/components/dashboard-bar-chart-layout"; +import { formatCompactWholeNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const DEFAULT_PRIMARY_COLOR = "#159C89"; +const STUB_COLOR = "#D7DBE2"; + +export type DashboardTokenDeveloperDatum = { + axisLabel: string; + fullLabel: string; + id: string; + imageUrl?: string; + sessions: number; + totalTokens: number; +}; + +type DashboardTokenDeveloperChartProps = { + activeId?: string | null; + barColor?: string; + className?: string; + data: DashboardTokenDeveloperDatum[]; + derivedLabel?: string; + formatDerivedValue?: (primaryValue: number, secondaryValue: number) => string; + formatPrimaryValue?: (value: number) => string; + formatSecondaryValue?: (value: number) => string; + primaryLabel?: string; + secondaryLabel?: string; + yAxisTickFormatter?: (value: number) => string; +}; + +type DashboardTokenDeveloperChartRow = DashboardTokenDeveloperDatum & { + committed: number; + stub: number; + uncommitted: number; +}; + +function formatCompactNumber(value: number) { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}K`; + } + + return value.toLocaleString(); +} + +function getAvatarInitials(fullLabel: string) { + const fallbackToken = fullLabel.includes("@") + ? (fullLabel.split("@")[0] ?? fullLabel) + : fullLabel; + const parts = fallbackToken.split(/\s+/).filter(Boolean); + + if (parts.length === 0) { + return "AI"; + } + + if (parts.length === 1) { + return parts[0]?.slice(0, 2).toUpperCase() ?? "AI"; + } + + return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase(); +} + +function getAxisStep(maxValue: number) { + if (maxValue <= 0) { + return 1; + } + + const roughStep = maxValue / 4; + const magnitude = 10 ** Math.floor(Math.log10(roughStep)); + const residual = roughStep / magnitude; + + if (residual <= 1) { + return magnitude; + } + + if (residual <= 2) { + return 2 * magnitude; + } + + if (residual <= 5) { + return 5 * magnitude; + } + + return 10 * magnitude; +} + +function getAxisMax(data: DashboardTokenDeveloperChartRow[]) { + const maxValue = Math.max(...data.map((point) => point.totalTokens), 0); + + if (maxValue <= 0) { + return 4; + } + + const step = getAxisStep(maxValue); + return Math.max(step * 4, Math.ceil(maxValue / step) * step); +} + +function getAxisTicks(axisMax: number) { + const step = getAxisStep(axisMax); + const ticks = Array.from( + { length: Math.floor(axisMax / step) + 1 }, + (_, index) => index * step, + ); + + return ticks.length > 1 ? ticks : [0, axisMax]; +} + +function DashboardTokenDeveloperTooltip({ + active, + derivedLabel, + formatDerivedValue, + formatPrimaryValue, + formatSecondaryValue, + payload, + primaryLabel, + secondaryLabel, +}: { + active?: boolean; + derivedLabel: string; + formatDerivedValue: (primaryValue: number, secondaryValue: number) => string; + formatPrimaryValue: (value: number) => string; + formatSecondaryValue: (value: number) => string; + payload?: Array<{ payload: DashboardTokenDeveloperChartRow }>; + primaryLabel: string; + secondaryLabel: string; +}) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + + if (!point) { + return null; + } + + return ( +
+
{point.fullLabel}
+
+
+ {primaryLabel} + + {formatPrimaryValue(point.totalTokens)} + +
+
+ {secondaryLabel} + + {formatSecondaryValue(point.sessions)} + +
+
+ {derivedLabel} + + {formatDerivedValue(point.totalTokens, point.sessions)} + +
+
+
+ ); +} + +function DashboardTokenDeveloperAxisTick({ + activeId, + dataById, + labelWidth, + payload, + x = 0, + y = 0, +}: { + activeId?: string | null; + dataById: Map; + labelWidth: number; + payload?: { value?: string | number }; + x?: number | string; + y?: number | string; +}) { + const datum = dataById.get(String(payload?.value ?? "")); + const resolvedX = typeof x === "number" ? x : Number(x ?? 0); + const resolvedY = typeof y === "number" ? y : Number(y ?? 0); + + if (!datum) { + return null; + } + + const isHighlighted = activeId != null && datum.id === activeId; + const hasExternalHighlight = activeId != null; + const contentOpacity = hasExternalHighlight && !isHighlighted ? 0.28 : 1; + const avatarBorderColor = isHighlighted + ? "color-mix(in srgb, var(--dashboardy-heading) 18%, var(--border))" + : undefined; + + return ( + + +
+
+
+ {datum.imageUrl ? ( + {datum.fullLabel} + ) : ( + + {getAvatarInitials(datum.fullLabel)} + + )} +
+ + {datum.axisLabel} + +
+
+
+
+ ); +} + +export function DashboardTokenDeveloperChart({ + activeId, + barColor = DEFAULT_PRIMARY_COLOR, + className, + data, + derivedLabel = "Avg / session", + formatDerivedValue = (primaryValue, secondaryValue) => + secondaryValue > 0 + ? formatCompactNumber(Math.round(primaryValue / secondaryValue)) + : "—", + formatPrimaryValue = formatCompactNumber, + formatSecondaryValue = (value) => value.toLocaleString(), + primaryLabel = "Tokens", + secondaryLabel = "Sessions", + yAxisTickFormatter = (value) => formatCompactWholeNumber(value), +}: DashboardTokenDeveloperChartProps) { + const chartData = useMemo( + () => + data.map((entry) => ({ + ...entry, + committed: entry.totalTokens, + stub: 0, + uncommitted: 0, + })), + [data], + ); + const dataById = useMemo( + () => new Map(chartData.map((entry) => [entry.id, entry] as const)), + [chartData], + ); + const resolvedActiveId = + activeId != null && dataById.has(activeId) ? activeId : null; + const axisMax = useMemo(() => getAxisMax(chartData), [chartData]); + const axisTicks = useMemo(() => getAxisTicks(axisMax), [axisMax]); + const barSize = useMemo( + () => getDashboardBarSize(chartData.length), + [chartData.length], + ); + const labelWidth = useMemo( + () => getDashboardBarLabelWidth(chartData.length), + [chartData.length], + ); + const chartConfig = useMemo( + () => + ({ + committed: { + label: primaryLabel, + color: barColor, + }, + uncommitted: { + label: primaryLabel, + color: barColor, + }, + stub: { + label: "No activity", + color: STUB_COLOR, + }, + }) satisfies ChartConfig, + [barColor, primaryLabel], + ); + + return ( +
+ + + ( + + )} + tickLine={false} + /> + yAxisTickFormatter(Number(value))} + tick={{ + fontSize: 13, + fontWeight: 800, + fill: "#9A9A9A", + }} + /> + + } + /> + + } + /> + + } + /> + + } + /> + + +
+ ); +} diff --git a/apps/web/src/features/dashboard/components/DashboardTopChartSection.tsx b/apps/web/src/features/dashboard/components/DashboardTopChartSection.tsx new file mode 100644 index 00000000..eb757b81 --- /dev/null +++ b/apps/web/src/features/dashboard/components/DashboardTopChartSection.tsx @@ -0,0 +1,102 @@ +import { + type ReactNode, + startTransition, + useCallback, + useRef, + useState, +} from "react"; +import { DashboardHeadlineMetricGrid } from "@/features/dashboard/components/DashboardHeadlineMetricGrid"; +import type { DashboardHeadlineMetric } from "@/features/dashboard/data/dashboard-static-data"; +import { cn } from "@/lib/utils"; + +type DashboardTopChartHighlightSource = "table" | null; + +export type DashboardTopChartRenderProps = { + highlightSource: DashboardTopChartHighlightSource; + highlightedItemId: string | null; + onHighlightItemChange: (itemId: string | null) => void; +}; + +export function DashboardTopChartSection({ + chart, + className, + detail, + isMetricsLoading = false, + metrics, + showDelta = false, +}: { + chart: ReactNode; + className?: string; + detail: ReactNode; + isMetricsLoading?: boolean; + metrics: DashboardHeadlineMetric[]; + showDelta?: boolean; +}) { + return ( +
+
+
+ +
+
+ {chart} +
+
+ {detail} +
+ ); +} + +export function DashboardInteractiveTopChartSection({ + className, + isMetricsLoading = false, + metrics, + renderChart, + renderDetail, + showDelta = false, +}: { + className?: string; + isMetricsLoading?: boolean; + metrics: DashboardHeadlineMetric[]; + renderChart: (props: DashboardTopChartRenderProps) => ReactNode; + renderDetail: (props: DashboardTopChartRenderProps) => ReactNode; + showDelta?: boolean; +}) { + const [highlightedItemId, setHighlightedItemId] = useState( + null, + ); + const highlightedItemIdRef = useRef(null); + + const handleHighlightItemChange = useCallback((itemId: string | null) => { + if (highlightedItemIdRef.current === itemId) { + return; + } + + highlightedItemIdRef.current = itemId; + startTransition(() => { + setHighlightedItemId(itemId); + }); + }, []); + + const renderProps: DashboardTopChartRenderProps = { + highlightSource: highlightedItemId ? "table" : null, + highlightedItemId, + onHighlightItemChange: handleHighlightItemChange, + }; + + return ( + + ); +} diff --git a/apps/web/src/features/dashboard/components/dashboard-bar-chart-layout.ts b/apps/web/src/features/dashboard/components/dashboard-bar-chart-layout.ts new file mode 100644 index 00000000..4880f48c --- /dev/null +++ b/apps/web/src/features/dashboard/components/dashboard-bar-chart-layout.ts @@ -0,0 +1,54 @@ +export function getDashboardBarSize(totalBars: number) { + if (totalBars <= 7) { + return 120; + } + + if (totalBars <= 10) { + return 108; + } + + if (totalBars <= 14) { + return 90; + } + + if (totalBars <= 18) { + return 78; + } + + return 66; +} + +export function getDashboardBarLabelWidth( + totalBars: number, + variant: "member" | "repository" = "member", +) { + if (variant === "repository") { + if (totalBars <= 7) { + return 128; + } + + if (totalBars <= 10) { + return 104; + } + + if (totalBars <= 14) { + return 88; + } + + return 76; + } + + if (totalBars <= 7) { + return 130; + } + + if (totalBars <= 10) { + return 108; + } + + if (totalBars <= 14) { + return 92; + } + + return 80; +} diff --git a/apps/web/src/features/dashboard/dashboard-theme.css b/apps/web/src/features/dashboard/dashboard-theme.css new file mode 100644 index 00000000..946dede5 --- /dev/null +++ b/apps/web/src/features/dashboard/dashboard-theme.css @@ -0,0 +1,958 @@ +.dashboardy-page { + --dashboardy-surface: rgba(255, 255, 255, 0.92); + --dashboardy-subsurface: rgba(245, 247, 250, 0.96); + --dashboardy-subsurface-strong: rgba(240, 243, 247, 0.98); + --dashboardy-border: rgba(15, 23, 42, 0.08); + --dashboardy-border-strong: rgba(15, 23, 42, 0.12); + --dashboardy-divider: rgba(148, 163, 184, 0.22); + --dashboardy-heading: #111827; + --dashboardy-subheading: #677489; + --dashboardy-muted: #667085; + --dashboardy-subtle: #98a2b3; + --dashboardy-accent: #2563eb; + --dashboard-01-tone-blue: #2f5fe5; + --dashboard-01-tone-teal: #25b5aa; + --dashboardy-card-shadow: + 0 1px 2px rgba(15, 23, 42, 0.06), 0 22px 44px -34px rgba(15, 23, 42, 0.28); + --dashboardy-chip-surface: rgba(238, 242, 255, 0.95); + --dashboardy-chip-foreground: #1d4ed8; + --dashboardy-chip-border: rgba(59, 130, 246, 0.15); + --dashboardy-success-surface: rgba(236, 253, 245, 0.95); + --dashboardy-success-foreground: #047857; + --dashboardy-warning-surface: rgba(255, 247, 237, 0.95); + --dashboardy-warning-foreground: #c2410c; + --dashboardy-danger-surface: rgba(254, 242, 242, 0.95); + --dashboardy-danger-foreground: #b42318; + --dashboardy-row-hover: rgba(239, 246, 255, 0.82); +} + +.dark .dashboardy-page { + --dashboardy-surface: rgba(17, 24, 39, 0.92); + --dashboardy-subsurface: rgba(30, 41, 59, 0.95); + --dashboardy-subsurface-strong: rgba(15, 23, 42, 0.98); + --dashboardy-border: rgba(148, 163, 184, 0.18); + --dashboardy-border-strong: rgba(148, 163, 184, 0.24); + --dashboardy-divider: rgba(148, 163, 184, 0.18); + --dashboardy-heading: #f8fafc; + --dashboardy-subheading: #b6c2d3; + --dashboardy-muted: #cbd5e1; + --dashboardy-subtle: #94a3b8; + --dashboardy-accent: #93c5fd; + --dashboard-01-tone-blue: #6d8fff; + --dashboard-01-tone-teal: #4fd3c9; + --dashboardy-card-shadow: + 0 1px 2px rgba(0, 0, 0, 0.14), 0 20px 44px -34px rgba(0, 0, 0, 0.48); + --dashboardy-chip-surface: rgba(30, 41, 59, 0.98); + --dashboardy-chip-foreground: #bfdbfe; + --dashboardy-chip-border: rgba(96, 165, 250, 0.18); + --dashboardy-success-surface: rgba(20, 83, 45, 0.36); + --dashboardy-success-foreground: #86efac; + --dashboardy-warning-surface: rgba(124, 45, 18, 0.36); + --dashboardy-warning-foreground: #fdba74; + --dashboardy-danger-surface: rgba(127, 29, 29, 0.4); + --dashboardy-danger-foreground: #fca5a5; + --dashboardy-row-hover: rgba(30, 41, 59, 0.9); +} + +.dashboardy-card { + background: var(--dashboardy-surface); + border-color: var(--dashboardy-border); + box-shadow: var(--dashboardy-card-shadow); +} + +.dashboardy-airport-title, +.dashboardy-section-title { + font-family: var(--app-font-heading); + font-weight: 800; + letter-spacing: -0.035em; + color: var(--dashboardy-heading); +} + +.dashboardy-location-copy, +.dashboardy-footnote, +.dashboardy-list-secondary, +.dashboardy-summary-copy { + color: var(--dashboardy-muted); +} + +.dashboardy-label, +.dashboardy-summary-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--dashboardy-subtle); +} + +.dashboardy-mono, +.dashboardy-list-value, +.dashboardy-board-times, +.dashboardy-preview-times { + font-family: "Geist Mono", ui-monospace, monospace; +} + +.dashboardy-action-button, +.dashboardy-link-button, +.dashboardy-toggle-item, +.dashboardy-tabs-trigger, +.dashboardy-sticky-action, +.dashboardy-sticky-share, +.dashboardy-sticky-tab { + transition: + background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), + color 180ms cubic-bezier(0.23, 1, 0.32, 1), + border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), + transform 160ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.dashboardy-hero { + gap: 0.75rem; +} + +.dashboardy-hero-card { + box-shadow: none; +} + +.dashboardy-identity-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + padding-bottom: 0.75rem; +} + +.dashboardy-identity-code-badge { + height: auto; + padding: 0; + border: 0; + background: transparent; + border-radius: 0; + font-family: var(--app-font-sans); + font-size: clamp(3rem, 6vw, 3.5rem); + line-height: 1; + font-weight: 700; + letter-spacing: -0.04em; + text-transform: uppercase; + color: var(--dashboardy-heading); +} + +.dashboardy-identity-separator { + height: 3.25rem; + background: var(--dashboardy-border); +} + +.dashboardy-identity-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.dashboardy-identity-name { + font-family: var(--app-font-sans); + font-size: 1rem; + font-weight: 500; + line-height: 1.2; + color: var(--dashboardy-heading); +} + +.dashboardy-identity-location, +.dashboardy-identity-subrow, +.dashboardy-identity-time, +.dashboardy-identity-weather { + font-family: var(--app-font-sans); + font-size: 0.875rem; + color: var(--dashboardy-muted); +} + +.dashboardy-identity-subrow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + text-transform: uppercase; +} + +.dashboardy-identity-weather { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.dashboardy-identity-weather [data-icon] { + color: var(--dashboardy-accent); +} + +.dashboardy-weather-badge-inline { + height: auto; + border: 0; + background: transparent; + padding: 0; + border-radius: 0; + font-size: 0.875rem; + font-weight: 400; + color: inherit; +} + +.dashboardy-sticky-nav { + position: sticky; + top: 0; + z-index: 10; + margin-inline: -1rem; + background: color-mix(in srgb, var(--dashboardy-surface) 98%, white); + padding-inline: 1rem; +} + +.dashboardy-sticky-nav-shell { + display: flex; + height: 3.875rem; + width: 100%; + align-items: center; + gap: 0.75rem; + overflow-x: auto; + border-bottom: 1px solid var(--dashboardy-border); + background: inherit; +} + +.dashboardy-sticky-tabs { + flex: 1; + min-width: fit-content; +} + +.dashboardy-sticky-tabs-list { + display: flex; + width: 100%; + min-width: fit-content; + align-items: center; + justify-content: flex-start; + gap: 0.375rem; + background: transparent; + padding: 0; +} + +.dashboardy-sticky-tab { + height: 2.125rem; + flex: none; + border-radius: 0.75rem; + padding-inline: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--dashboardy-muted); +} + +.dashboardy-sticky-tab[data-active], +.dashboardy-sticky-tab[data-state="on"] { + background: var(--dashboardy-subsurface-strong); + color: var(--dashboardy-heading); + box-shadow: none; +} + +.dashboardy-sticky-actions { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--app-font-sans); +} + +.dashboardy-sticky-action, +.dashboardy-sticky-share { + height: 2.375rem; + padding-inline: 0.75rem; + font-size: 0.875rem; + font-weight: 400; + white-space: nowrap; +} + +.dashboardy-sticky-action { + border-radius: 999px; +} + +.dashboardy-sticky-share { + border-radius: 999px; +} + +.dashboardy-toggle-group { + border-radius: 999px; +} + +.dashboardy-toggle-item { + color: var(--dashboardy-muted); +} + +.dashboardy-toggle-item[data-state="on"] { + background: rgba(255, 255, 255, 0.92); + color: var(--dashboardy-heading); +} + +.dark .dashboardy-toggle-item[data-state="on"] { + background: rgba(15, 23, 42, 0.88); +} + +.dashboardy-status-card { + background: #fffaeb; +} + +.dark .dashboardy-status-card { + background: rgba(120, 53, 15, 0.32); +} + +.dashboardy-status-indicator, +.dashboardy-status-indicator-middle { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; +} + +.dashboardy-status-indicator { + height: 1.25rem; + width: 1.25rem; + background: #ffedd5; +} + +.dashboardy-status-indicator-middle { + height: 0.875rem; + width: 0.875rem; + background: #fed7aa; +} + +.dashboardy-status-indicator-inner { + height: 0.5rem; + width: 0.5rem; + border-radius: 999px; + background: #f79009; +} + +.dashboardy-status-content { + display: flex; + flex: 1; + flex-direction: column; +} + +.dashboardy-status-scroll { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.75rem; + overflow-y: auto; + padding-bottom: 0.25rem; + padding-right: 0.25rem; +} + +@media (min-width: 640px) { + .dashboardy-status-scroll { + max-height: 25rem; + } +} + +.dashboardy-status-row { + display: flex; + gap: 0.75rem; +} + +.dashboardy-status-row-icon { + color: var(--dashboardy-heading); +} + +.dashboardy-status-row-icon > svg { + height: 1.25rem; + width: 1.25rem; +} + +.dashboardy-status-row-copy { + display: flex; + flex: 1; + flex-direction: column; + border-bottom: 1px solid rgb(0 0 0 / 0.1); + padding-right: 1.75rem; + padding-bottom: 0.75rem; +} + +.dashboardy-status-row-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--dashboardy-heading); +} + +.dashboardy-status-row-description { + font-size: 0.95rem; + font-weight: 500; + color: rgb(0 0 0 / 0.6); +} + +.dark .dashboardy-status-row-description { + color: rgb(248 250 252 / 0.72); +} + +.dashboardy-status-footer { + font-size: 0.95rem; + font-weight: 600; +} + +.dashboardy-status-footer > svg { + transition: opacity 150ms ease; +} + +.dashboardy-status-footer:hover > svg { + opacity: 0.7; +} + +.dashboardy-airline-avatar { + background: var(--dashboardy-subsurface); +} + +.dashboardy-airline-avatar-fallback { + background: var(--dashboardy-subsurface); + color: var(--dashboardy-heading); + font-family: "Geist Mono", ui-monospace, monospace; + font-size: 0.68rem; + font-weight: 700; +} + +.dashboardy-route-badge { + border-color: var(--dashboardy-border); + background: var(--dashboardy-subsurface); + color: var(--dashboardy-heading); + font-family: "Geist Mono", ui-monospace, monospace; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.dashboardy-bucket-card, +.dashboardy-stat-tile { + border-radius: 1.2rem; + border: 1px solid var(--dashboardy-border); + background: var(--dashboardy-subsurface); + padding: 1rem; +} + +.dashboardy-bucket-percentage, +.dashboardy-stat-value { + font-size: 1.65rem; + line-height: 1; + font-weight: 700; + letter-spacing: -0.05em; + color: var(--dashboardy-heading); +} + +.dashboardy-bucket-count, +.dashboardy-list-value { + font-size: 0.8rem; + font-weight: 600; + color: var(--dashboardy-heading); +} + +.dashboardy-delay-card { + background: var(--dashboardy-subsurface); +} + +.dashboardy-delay-header-icon { + display: inline-flex; + color: var(--dashboardy-heading); +} + +.dashboardy-delay-header-icon > svg { + height: 1.25rem; + width: 1.25rem; +} + +.dashboardy-delay-stats { + display: grid; + gap: 0.75rem; + padding-bottom: 1rem; +} + +.dashboardy-delay-stat { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.625rem; +} + +.dashboardy-delay-tone { + display: inline-flex; + height: 0.75rem; + width: 0.1875rem; + border-radius: 999px; +} + +.dashboardy-delay-tone--ontime { + background: #039855; +} + +.dashboardy-delay-tone--late { + background: #f79009; +} + +.dashboardy-delay-tone--canceled { + background: + repeating-linear-gradient( + -45deg, + rgba(0, 0, 0, 0.12), + rgba(0, 0, 0, 0.12) 2px, + transparent 2px, + transparent 4px + ), + #f04438; +} + +.dashboardy-delay-tone--diverted { + background: + repeating-linear-gradient( + -45deg, + rgba(0, 0, 0, 0.12), + rgba(0, 0, 0, 0.12) 2px, + transparent 2px, + transparent 4px + ), + #98a2b3; +} + +.dashboardy-delay-stat-label { + font-size: 0.72rem; + font-weight: 600; + line-height: 1; + color: var(--dashboardy-muted); +} + +.dashboardy-delay-stat-percentage { + font-size: clamp(1.625rem, 4vw, 2.25rem); + line-height: 1; + font-weight: 600; + letter-spacing: -0.04em; + color: var(--dashboardy-heading); +} + +.dashboardy-delay-stat-count { + font-size: 0.95rem; + font-weight: 600; + color: var(--dashboardy-muted); +} + +.dashboardy-delay-progress { + display: flex; + height: 0.375rem; + width: 100%; + gap: 0.125rem; + overflow: hidden; + border-radius: 999px; +} + +.dashboardy-delay-progress-segment { + height: 100%; + border-radius: 999px; +} + +.dashboardy-delay-progress-segment.dashboardy-delay-tone--ontime { + background: #039855; +} + +.dashboardy-delay-progress-segment.dashboardy-delay-tone--late { + background: #f79009; +} + +.dashboardy-delay-progress-segment.dashboardy-delay-tone--canceled { + background: + repeating-linear-gradient( + -45deg, + rgba(0, 0, 0, 0.12), + rgba(0, 0, 0, 0.12) 4px, + transparent 4px, + transparent 6px + ), + #f04438; +} + +.dashboardy-delay-progress-segment.dashboardy-delay-tone--diverted { + background: + repeating-linear-gradient( + -45deg, + rgba(0, 0, 0, 0.12), + rgba(0, 0, 0, 0.12) 4px, + transparent 4px, + transparent 6px + ), + #98a2b3; +} + +.dashboardy-delay-chart-heading { + padding-bottom: 1rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--dashboardy-muted); +} + +.dashboardy-delay-chart-shell { + position: relative; + height: 15rem; + width: 100%; +} + +.dashboardy-delay-chart-shell .recharts-reference-line-line { + stroke: var(--dashboardy-divider); +} + +.dashboardy-delay-chart-shell .recharts-bar-rectangle, +.dashboardy-delay-chart-shell .recharts-rectangle { + cursor: pointer; + transition: + opacity 160ms ease-out, + filter 160ms ease-out, + transform 160ms ease-out; +} + +.dashboardy-delay-tooltip { + max-width: 15rem; +} + +.dashboardy-delay-tooltip-indicator { + height: 0.75rem; + width: 0.1875rem; +} + +.dashboardy-weather-badge, +.dashboardy-inline-badge { + height: auto; + border-color: var(--dashboardy-border); + background: var(--dashboardy-subsurface-strong); + color: var(--dashboardy-heading); +} + +.dashboardy-weather-metric-badge, +.dashboardy-meter-badge { + height: auto; + border-color: var(--dashboardy-border); + background: transparent; + color: var(--dashboardy-muted); +} + +.dashboardy-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.9rem; + padding-block: 0.85rem; +} + +.dashboardy-list-row + .dashboardy-list-row { + border-top: 1px solid var(--dashboardy-divider); +} + +.dashboardy-list-primary { + font-size: 0.92rem; + font-weight: 600; + color: var(--dashboardy-heading); +} + +.dashboardy-preview-heading { + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--dashboardy-heading); +} + +.dashboardy-preview-action { + height: auto; + padding-inline: 0; + font-size: 0.83rem; + font-weight: 500; + text-decoration: none; +} + +.dashboardy-preview-feed-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + border-radius: 0.5rem; + padding: 0.5rem 1rem; +} + +.dashboardy-preview-feed-row:nth-child(even) { + background: var(--dashboardy-subsurface-strong); +} + +.dashboardy-preview-primary { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: flex-start; + gap: 0.875rem; +} + +.dashboardy-preview-times, +.dashboardy-board-times { + display: flex; + flex-direction: column; + gap: 0.08rem; + min-width: 3.75rem; +} + +.dashboardy-preview-time, +.dashboardy-board-actual { + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.015em; +} + +.dashboardy-preview-time { + color: var(--dashboardy-heading); + white-space: nowrap; +} + +.dashboardy-preview-time--struck { + color: color-mix(in srgb, var(--dashboardy-muted) 92%, white); + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +.dashboardy-board-actual { + color: var(--dashboardy-heading); +} + +.dashboardy-board-actual--green { + color: #027a48; +} + +.dashboardy-board-actual--orange { + color: #c26d05; +} + +.dashboardy-board-actual--red { + color: #d92d20; +} + +.dashboardy-preview-city { + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.05; + letter-spacing: 0.015em; + color: var(--dashboardy-heading); + white-space: nowrap; +} + +.dashboardy-preview-status { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.dashboardy-board-status-dot { + display: inline-flex; + height: 0.5rem; + width: 0.5rem; + flex: none; + border-radius: 999px; + background: #039855; +} + +.dashboardy-board-status-dot--orange { + background: #f79009; +} + +.dashboardy-board-status-dot--red { + background: #f04438; +} + +.dashboardy-preview-detail { + font-size: 0.8125rem; + font-weight: 500; + line-height: 1; + color: var(--dashboardy-muted); + text-align: right; + white-space: nowrap; +} + +.dashboardy-preview-meta { + display: flex; + min-width: 4.9rem; + flex: none; + flex-direction: column; + align-items: flex-end; + gap: 0.125rem; +} + +.dashboardy-preview-flight-button { + height: auto; + min-height: 0; + padding: 0; + border-radius: 0.375rem; + color: inherit; + justify-content: flex-end; +} + +.dashboardy-preview-flight-button:hover, +.dashboardy-preview-flight-button:focus-visible { + background: transparent; + opacity: 0.7; +} + +.dashboardy-preview-flight-button [data-slot="avatar"] { + flex: none; +} + +.dashboardy-preview-flight-code { + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.015em; + color: var(--dashboardy-heading); + white-space: nowrap; +} + +.dashboardy-delay-meter { + display: flex; + height: 0.5rem; + overflow: hidden; + border-radius: 999px; + background: var(--dashboardy-subsurface); +} + +.dashboardy-delay-meter-segment { + height: 100%; +} + +.dashboardy-delay-meter-segment--ontime { + background: color-mix(in srgb, var(--dashboardy-accent) 24%, white); +} + +.dashboardy-delay-meter-segment--delayed { + background: color-mix(in srgb, var(--dashboardy-accent) 72%, white); +} + +.dashboardy-delay-meter-segment--disrupted { + background: var(--dashboardy-warning-foreground); +} + +.dashboardy-performance-meter .dashboardy-delay-meter-segment:first-child { + background: color-mix(in srgb, var(--dashboardy-accent) 18%, white); +} + +.dashboardy-performance-meter .dashboardy-delay-meter-segment:nth-child(2) { + background: color-mix(in srgb, var(--dashboardy-accent) 48%, white); +} + +.dashboardy-performance-meter .dashboardy-delay-meter-segment:last-child { + background: color-mix(in srgb, var(--dashboardy-accent) 76%, white); +} + +.dashboardy-metric-bar { + height: 0.35rem; + overflow: hidden; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); +} + +.dashboardy-metric-bar-fill { + display: block; + height: 100%; + border-radius: inherit; + background: color-mix(in srgb, var(--dashboardy-accent) 72%, white); +} + +.dashboardy-board-table [data-slot="table-row"] { + border-color: var(--dashboardy-divider); +} + +.dashboardy-board-table [data-slot="table-cell"] { + padding-block: 0.95rem; + background: transparent; +} + +.dashboardy-board-row:hover [data-slot="table-cell"] { + background: var(--dashboardy-row-hover); +} + +.dashboardy-board-time-cell { + width: 7rem; +} + +.dashboardy-board-route-cell { + min-width: 12rem; +} + +.dashboardy-board-meta-cell { + width: 14rem; +} + +.dashboardy-board-status-copy { + font-size: 0.8125rem; + font-weight: 400; + line-height: 1; + color: var(--dashboardy-heading); +} + +.dashboardy-preview-status-copy { + display: block; + font-size: 0.8125rem; + white-space: nowrap; +} + +.dashboardy-airline-avatar-fallback--compact { + border-radius: 0.2rem; + font-size: 0.46rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.dashboardy-board-status-copy--orange { + color: #b54708; +} + +.dashboardy-board-status-copy--red { + color: #d92d20; +} + +@media (min-width: 640px) { + .dashboardy-identity-row { + flex-direction: row; + align-items: center; + gap: 1rem; + } + + .dashboardy-identity-meta { + padding-left: 0; + } + + .dashboardy-sticky-nav { + margin-inline: 0; + padding-inline: 0; + } + + .dashboardy-sticky-tabs-list { + gap: 0.5rem; + } + + .dashboardy-sticky-tab { + padding-inline: 0.75rem; + } +} + +@media (max-width: 1024px) { + .dashboardy-board-meta-cell { + width: 12rem; + } +} + +@media (max-width: 640px) { + .dashboardy-hero-pill { + width: 100%; + } + + .dashboardy-preview-feed-row { + flex-direction: column; + } + + .dashboardy-preview-feed-row > :last-child { + padding-left: 4.625rem; + } + + .dashboardy-board-time-cell { + width: 5.75rem; + } + + .dashboardy-board-meta-cell { + width: 10.5rem; + } +} diff --git a/apps/web/src/features/dashboard/data/dashboard-performance-adapter.ts b/apps/web/src/features/dashboard/data/dashboard-performance-adapter.ts new file mode 100644 index 00000000..4f98b3a4 --- /dev/null +++ b/apps/web/src/features/dashboard/data/dashboard-performance-adapter.ts @@ -0,0 +1,287 @@ +import type { UserDailyTrendData, UserTokenUsageData } from "@rudel/api-routes"; +import { calculateCost } from "@/lib/format"; + +export type DashboardPerformanceUserComparison = { + commits: number; + cost: number; + imageUrl?: string | null; + inputTokens: number; + label: string; + modelsUsed: string[]; + outputTokens: number; + repositoriesTouched: string[]; + sessions: number; + totalTokens: number; + userId: string; +}; + +type DashboardPerformanceMember = { + userId: string; + user: { + email: string; + image: string | null; + name: string; + }; +}; + +type SortablePerformanceUser = { + cost: number; + imageUrl?: string | null; + inputTokens: number; + modelsUsed: string[]; + outputTokens: number; + repositoriesTouched: string[]; + totalCommits: number; + totalSessions: number; + totalTokens: number; + userId: string; + userLabel: string; +}; + +type AggregatedTrendUsage = { + inputTokens: number; + modelsUsed: string[]; + outputTokens: number; + repositoriesTouched: string[]; + totalCommits: number; + totalSessions: number; + totalTokens: number; +}; + +type ResolvedPerformanceTotals = { + cost: number; + inputTokens: number; + outputTokens: number; + totalCommits: number; + totalSessions: number; + totalTokens: number; +}; + +function getMemberDisplayLabel(member: DashboardPerformanceMember) { + const trimmedName = member.user.name.trim(); + + return trimmedName || member.user.email || "Unknown user"; +} + +function buildTrendUsageByUserId( + usersDailyTrend: UserDailyTrendData[] | undefined, +) { + const usageByUserId = new Map< + string, + AggregatedTrendUsage & { + modelCounts: Map; + repositoryCounts: Map; + } + >(); + + for (const row of usersDailyTrend ?? []) { + const currentUsage = usageByUserId.get(row.user_id) ?? { + inputTokens: 0, + modelCounts: new Map(), + modelsUsed: [], + outputTokens: 0, + repositoriesTouched: [], + repositoryCounts: new Map(), + totalCommits: 0, + totalSessions: 0, + totalTokens: 0, + }; + + currentUsage.inputTokens += row.input_tokens ?? 0; + currentUsage.outputTokens += row.output_tokens ?? 0; + currentUsage.totalCommits += row.total_commits ?? 0; + currentUsage.totalSessions += row.sessions ?? 0; + currentUsage.totalTokens += row.total_tokens ?? 0; + for (const model of row.models_used ?? []) { + if (!model) { + continue; + } + + currentUsage.modelCounts.set( + model, + (currentUsage.modelCounts.get(model) ?? 0) + 1, + ); + } + for (const repository of row.repositories_touched ?? []) { + if (!repository) { + continue; + } + + currentUsage.repositoryCounts.set( + repository, + (currentUsage.repositoryCounts.get(repository) ?? 0) + 1, + ); + } + usageByUserId.set(row.user_id, currentUsage); + } + + return new Map( + Array.from(usageByUserId.entries()).map(([userId, usage]) => [ + userId, + { + inputTokens: usage.inputTokens, + modelsUsed: Array.from(usage.modelCounts.entries()) + .sort( + (left, right) => + right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .map(([model]) => model), + outputTokens: usage.outputTokens, + repositoriesTouched: Array.from(usage.repositoryCounts.entries()) + .sort( + (left, right) => + right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .map(([repository]) => repository), + totalCommits: usage.totalCommits, + totalSessions: usage.totalSessions, + totalTokens: usage.totalTokens, + }, + ]), + ); +} + +function resolvePerformanceTotals( + usage: UserTokenUsageData | undefined, + trendUsage: AggregatedTrendUsage | undefined, +): ResolvedPerformanceTotals { + const inputTokens = trendUsage?.inputTokens ?? usage?.input_tokens ?? 0; + const outputTokens = trendUsage?.outputTokens ?? usage?.output_tokens ?? 0; + const totalCommits = trendUsage?.totalCommits ?? usage?.total_commits ?? 0; + const totalSessions = trendUsage?.totalSessions ?? usage?.total_sessions ?? 0; + const totalTokens = trendUsage?.totalTokens ?? usage?.total_tokens ?? 0; + + return { + cost: + usage && usage.cost > 0 + ? usage.cost + : calculateCost(inputTokens, outputTokens), + inputTokens, + outputTokens, + totalCommits, + totalSessions, + totalTokens, + }; +} + +export function buildDashboardPerformanceUsers( + usersTokenUsage: UserTokenUsageData[] | undefined, + usersDailyTrend: UserDailyTrendData[] | undefined, + userImageById: Map, + organizationMembers: readonly DashboardPerformanceMember[] = [], +): DashboardPerformanceUserComparison[] { + const usageByUserId = new Map( + (usersTokenUsage ?? []).map((user) => [user.user_id, user] as const), + ); + const trendUsageByUserId = buildTrendUsageByUserId(usersDailyTrend); + const memberIds = new Set(organizationMembers.map((member) => member.userId)); + + const organizationRows: SortablePerformanceUser[] = organizationMembers.map( + (member) => { + const usage = usageByUserId.get(member.userId); + const trendUsage = trendUsageByUserId.get(member.userId); + const { + cost, + inputTokens, + outputTokens, + totalCommits, + totalSessions, + totalTokens, + } = resolvePerformanceTotals(usage, trendUsage); + + return { + cost, + imageUrl: member.user.image, + inputTokens, + modelsUsed: usage?.models_used.length + ? usage.models_used + : (trendUsage?.modelsUsed ?? []), + outputTokens, + repositoriesTouched: usage?.repositories_touched.length + ? usage.repositories_touched + : (trendUsage?.repositoriesTouched ?? []), + totalCommits, + totalSessions, + totalTokens, + userId: member.userId, + userLabel: usage?.user_label || getMemberDisplayLabel(member), + }; + }, + ); + + const analyticsOnlyIds = new Set([ + ...(usersTokenUsage ?? []).map((user) => user.user_id), + ...(usersDailyTrend ?? []).map((row) => row.user_id), + ]); + + const analyticsOnlyRows: SortablePerformanceUser[] = Array.from( + analyticsOnlyIds, + ) + .filter((userId) => userId && !memberIds.has(userId)) + .map((userId) => { + const usage = usageByUserId.get(userId); + const trendUsage = trendUsageByUserId.get(userId); + const { + cost, + inputTokens, + outputTokens, + totalCommits, + totalSessions, + totalTokens, + } = resolvePerformanceTotals(usage, trendUsage); + + return { + cost, + imageUrl: userImageById.get(userId) ?? null, + inputTokens, + modelsUsed: usage?.models_used.length + ? usage.models_used + : (trendUsage?.modelsUsed ?? []), + outputTokens, + repositoriesTouched: usage?.repositories_touched.length + ? usage.repositories_touched + : (trendUsage?.repositoriesTouched ?? []), + totalCommits, + totalSessions, + totalTokens, + userId, + userLabel: usage?.user_label ?? userId, + }; + }); + + const combinedRows = [...organizationRows, ...analyticsOnlyRows]; + + if (combinedRows.length === 0) { + return []; + } + + return combinedRows + .sort((left, right) => { + if (right.totalCommits !== left.totalCommits) { + return right.totalCommits - left.totalCommits; + } + + if (right.totalSessions !== left.totalSessions) { + return right.totalSessions - left.totalSessions; + } + + if (right.totalTokens !== left.totalTokens) { + return right.totalTokens - left.totalTokens; + } + + return left.userLabel.localeCompare(right.userLabel); + }) + .map((user) => ({ + commits: user.totalCommits, + cost: user.cost, + imageUrl: user.imageUrl ?? userImageById.get(user.userId) ?? null, + inputTokens: user.inputTokens, + label: user.userLabel, + modelsUsed: user.modelsUsed, + outputTokens: user.outputTokens, + repositoriesTouched: user.repositoriesTouched, + sessions: user.totalSessions, + totalTokens: user.totalTokens, + userId: user.userId, + })); +} diff --git a/apps/web/src/features/dashboard/data/dashboard-performance-trend.ts b/apps/web/src/features/dashboard/data/dashboard-performance-trend.ts new file mode 100644 index 00000000..3eac5c5d --- /dev/null +++ b/apps/web/src/features/dashboard/data/dashboard-performance-trend.ts @@ -0,0 +1,86 @@ +import type { UserDailyTrendData } from "@rudel/api-routes"; +import type { DashboardPerformanceUserComparison } from "@/features/dashboard/data/dashboard-performance-adapter"; + +export const DASHBOARD_PERFORMANCE_TREND_COLORS: string[] = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#8b5cf6", + "#ef4444", + "#14b8a6", + "#f97316", + "#6366f1", + "#84cc16", + "#ec4899", + "#06b6d4", + "#a855f7", +] as const; + +export type DashboardPerformanceTrendMetric = + | "sessions" + | "commits" + | "tokens" + | "repositories"; + +export type DashboardPerformanceTrendSeries = { + color: string; + label: string; + userId: string; +}; + +export function getDashboardPerformanceTrendValue( + row: UserDailyTrendData | undefined, + metric: DashboardPerformanceTrendMetric, +) { + if (!row) { + return 0; + } + + if (metric === "sessions") { + return row.sessions; + } + + if (metric === "tokens") { + return row.total_tokens; + } + + if (metric === "repositories") { + return row.repositories_touched.length; + } + + return row.total_commits; +} + +export function buildDashboardPerformanceTrendSeries( + performanceUsers: DashboardPerformanceUserComparison[], + trendData: UserDailyTrendData[] | undefined, + metric: DashboardPerformanceTrendMetric, +): DashboardPerformanceTrendSeries[] { + const rows = trendData ?? []; + + return performanceUsers + .filter((user) => rows.some((row) => row.user_id === user.userId)) + .map((user, index) => { + const hasMetricActivity = rows.some( + (row) => + row.user_id === user.userId && + getDashboardPerformanceTrendValue(row, metric) > 0, + ); + + if (!hasMetricActivity) { + return null; + } + + return { + color: + DASHBOARD_PERFORMANCE_TREND_COLORS[ + index % DASHBOARD_PERFORMANCE_TREND_COLORS.length + ], + label: user.label, + userId: user.userId, + } satisfies DashboardPerformanceTrendSeries; + }) + .filter( + (series): series is DashboardPerformanceTrendSeries => series != null, + ); +} diff --git a/apps/web/src/features/dashboard/data/dashboard-repository-trend.ts b/apps/web/src/features/dashboard/data/dashboard-repository-trend.ts new file mode 100644 index 00000000..b4083d9f --- /dev/null +++ b/apps/web/src/features/dashboard/data/dashboard-repository-trend.ts @@ -0,0 +1,152 @@ +import type { RepositoryDailyTrendData } from "@rudel/api-routes"; +import type { DashboardRankedOutputRow } from "@/features/dashboard/data/dashboard-static-data"; + +export const DASHBOARD_REPOSITORY_TREND_COLORS: string[] = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#8b5cf6", + "#ef4444", + "#14b8a6", + "#f97316", + "#6366f1", + "#84cc16", + "#ec4899", + "#06b6d4", + "#a855f7", +] as const; + +export type DashboardRepositoryTrendMetric = "sessions" | "commits"; + +export type DashboardRepositorySummaryRow = { + activeDays: number | null; + commitRate: number; + commits: number; + id: string; + label: string; + sessions: number; +}; + +type DashboardRepositorySortMetric = "commits" | "sessions"; + +export type DashboardRepositoryTrendSeries = { + color: string; + label: string; + repositoryId: string; +}; + +function getCommitRate(commits: number, sessions: number) { + if (sessions <= 0) { + return 0; + } + + return Math.round((commits / sessions) * 100); +} + +export function getDashboardRepositoryTrendValue( + row: RepositoryDailyTrendData | undefined, + metric: DashboardRepositoryTrendMetric, +) { + if (!row) { + return 0; + } + + return metric === "sessions" ? row.sessions : row.total_commits; +} + +export function buildDashboardRepositorySummaryRows( + fallbackRows: DashboardRankedOutputRow[], + trendData: RepositoryDailyTrendData[] | undefined, + sortBy: DashboardRepositorySortMetric = "commits", +): DashboardRepositorySummaryRow[] { + const rows = trendData ?? []; + + if (rows.length === 0) { + return [...fallbackRows] + .map((row) => ({ + activeDays: null, + commitRate: row.commitRate, + commits: row.commits, + id: row.label, + label: row.label, + sessions: row.sessions, + })) + .sort( + (left, right) => + (sortBy === "sessions" + ? right.sessions - left.sessions || right.commits - left.commits + : right.commits - left.commits || right.sessions - left.sessions) || + left.label.localeCompare(right.label), + ); + } + + const repositoryMap = new Map< + string, + { + activeDays: Set; + commits: number; + label: string; + sessions: number; + } + >(); + + for (const row of rows) { + const existingRow = repositoryMap.get(row.repository); + + if (existingRow) { + existingRow.sessions += row.sessions; + existingRow.commits += row.total_commits; + existingRow.activeDays.add(row.date); + continue; + } + + repositoryMap.set(row.repository, { + activeDays: new Set([row.date]), + commits: row.total_commits, + label: row.repository, + sessions: row.sessions, + }); + } + + return Array.from(repositoryMap.entries()) + .map(([repositoryId, row]) => ({ + activeDays: row.activeDays.size, + commitRate: getCommitRate(row.commits, row.sessions), + commits: row.commits, + id: repositoryId, + label: row.label, + sessions: row.sessions, + })) + .sort( + (left, right) => + (sortBy === "sessions" + ? right.sessions - left.sessions || right.commits - left.commits + : right.commits - left.commits || right.sessions - left.sessions) || + left.label.localeCompare(right.label), + ); +} + +export function buildDashboardRepositoryTrendSeries( + summaryRows: DashboardRepositorySummaryRow[], + trendData: RepositoryDailyTrendData[] | undefined, + metric: DashboardRepositoryTrendMetric, +): DashboardRepositoryTrendSeries[] { + const rows = trendData ?? []; + + return summaryRows + .filter((summaryRow) => + rows.some( + (row) => + row.repository === summaryRow.id && + getDashboardRepositoryTrendValue(row, metric) > 0, + ), + ) + .map((summaryRow, index) => ({ + color: + DASHBOARD_REPOSITORY_TREND_COLORS[ + index % DASHBOARD_REPOSITORY_TREND_COLORS.length + ], + label: summaryRow.label, + repositoryId: summaryRow.id, + })); +} diff --git a/apps/web/src/features/dashboard/data/dashboard-roi-adapter.ts b/apps/web/src/features/dashboard/data/dashboard-roi-adapter.ts new file mode 100644 index 00000000..9a715b76 --- /dev/null +++ b/apps/web/src/features/dashboard/data/dashboard-roi-adapter.ts @@ -0,0 +1,175 @@ +import type { ROIDashboard } from "@rudel/api-routes"; +import { + addMonths, + addWeeks, + eachDayOfInterval, + format, + parseISO, + startOfMonth, + startOfWeek, +} from "date-fns"; +import type { + DashboardDailyPatternPoint, + DashboardHeadlineMetric, + DashboardOutputSnapshot, +} from "@/features/dashboard/data/dashboard-static-data"; + +function formatMetricValue(value: number) { + return new Intl.NumberFormat("en-US").format(value); +} + +function getCommitRate(commits: number, sessions: number) { + if (sessions <= 0) { + return 0; + } + + return Math.round((commits / sessions) * 100); +} + +function buildHeadlineMetrics( + currentMetrics: DashboardHeadlineMetric[], + roiDashboard: ROIDashboard, +) { + const committedSessions = roiDashboard.summary.total_commits; + const totalSessions = roiDashboard.summary.total_sessions; + const uncommittedSessions = Math.max(totalSessions - committedSessions, 0); + const commitRate = getCommitRate(committedSessions, totalSessions); + + return currentMetrics.map((metric) => { + if (metric.id === "uncommitted") { + return { + ...metric, + label: "Uncommitted sessions", + valueLabel: formatMetricValue(uncommittedSessions), + }; + } + + if (metric.id === "sessions") { + return { + ...metric, + valueLabel: formatMetricValue(totalSessions), + }; + } + + return { + ...metric, + valueLabel: `${commitRate}%`, + }; + }); +} + +function buildBucketDates(roiDashboard: ROIDashboard) { + const startDate = parseISO(roiDashboard.start_date); + const endDate = parseISO(roiDashboard.end_date); + + if ( + Number.isNaN(startDate.getTime()) || + Number.isNaN(endDate.getTime()) || + startDate.getTime() > endDate.getTime() + ) { + return []; + } + + if (roiDashboard.trend_interval === "day") { + return eachDayOfInterval({ start: startDate, end: endDate }); + } + + if (roiDashboard.trend_interval === "week") { + const buckets: Date[] = []; + let cursor = startOfWeek(startDate, { weekStartsOn: 1 }); + const lastBucket = startOfWeek(endDate, { weekStartsOn: 1 }); + + while (cursor.getTime() <= lastBucket.getTime()) { + buckets.push(cursor); + cursor = addWeeks(cursor, 1); + } + + return buckets; + } + + const buckets: Date[] = []; + let cursor = startOfMonth(startDate); + const lastBucket = startOfMonth(endDate); + + while (cursor.getTime() <= lastBucket.getTime()) { + buckets.push(cursor); + cursor = addMonths(cursor, 1); + } + + return buckets; +} + +function formatBucketAxisLabel( + date: Date, + interval: ROIDashboard["trend_interval"], +) { + if (interval === "day") { + return format(date, "EEE"); + } + + if (interval === "week") { + return format(date, "MMM d"); + } + + return format(date, "MMM"); +} + +function formatBucketFullLabel( + date: Date, + interval: ROIDashboard["trend_interval"], +) { + if (interval === "day") { + return format(date, "EEEE, MMM d"); + } + + if (interval === "week") { + return `Week of ${format(date, "MMM d")}`; + } + + return format(date, "MMMM yyyy"); +} + +function buildDailyPattern( + roiDashboard: ROIDashboard, +): DashboardDailyPatternPoint[] { + const trendByBucket = new Map( + roiDashboard.trend.map((row) => [row.bucket_start, row] as const), + ); + + return buildBucketDates(roiDashboard).map((bucketDate) => { + const bucketKey = format(bucketDate, "yyyy-MM-dd"); + const bucket = trendByBucket.get(bucketKey); + const sessions = bucket?.total_sessions ?? null; + const commits = bucket?.total_commits ?? null; + + return { + date: bucketKey, + axisLabel: formatBucketAxisLabel(bucketDate, roiDashboard.trend_interval), + fullLabel: formatBucketFullLabel(bucketDate, roiDashboard.trend_interval), + commits, + sessions, + commitRate: + sessions != null && commits != null && sessions > 0 + ? getCommitRate(commits, sessions) + : null, + }; + }); +} + +export function mergeDashboardSnapshotWithRoi( + currentSnapshot: DashboardOutputSnapshot, + roiDashboard: ROIDashboard | undefined, +): DashboardOutputSnapshot { + if (!roiDashboard) { + return currentSnapshot; + } + + return { + ...currentSnapshot, + headlineMetrics: buildHeadlineMetrics( + currentSnapshot.headlineMetrics, + roiDashboard, + ), + dailyPattern: buildDailyPattern(roiDashboard), + }; +} diff --git a/apps/web/src/features/dashboard/data/dashboard-static-data.ts b/apps/web/src/features/dashboard/data/dashboard-static-data.ts new file mode 100644 index 00000000..f5c5182f --- /dev/null +++ b/apps/web/src/features/dashboard/data/dashboard-static-data.ts @@ -0,0 +1,750 @@ +import { + addDays, + eachDayOfInterval, + format, + getISODay, + parseISO, + startOfWeek, +} from "date-fns"; + +export type DashboardDeltaTone = "positive" | "negative" | "neutral"; +export type DashboardMetricId = + | "output" + | "quality" + | "efficiency" + | "speed" + | "craft" + | "consistency"; + +export interface DashboardMetricTrendPoint { + date: string; + value: number | null; +} + +export interface DashboardMetricMemberPoint { + imageUrl?: string | null; + label: string; + userId?: string; + value: number | null; +} + +export interface DashboardGroupedDetailPoint { + date: string; + primary: number | null; + secondary: number | null; +} + +export interface DashboardSingleDetailPoint { + date: string; + value: number | null; +} + +export interface DashboardMetricDetailData { + grouped: { + primaryLabel: string; + secondaryLabel: string; + points: DashboardGroupedDetailPoint[]; + }; + single: { + label: string; + points: DashboardSingleDetailPoint[]; + }; +} + +export interface DashboardMetric { + id: DashboardMetricId; + label: string; + value: number; + deltaLabel: string; + deltaTone: DashboardDeltaTone; + trend: DashboardMetricTrendPoint[]; + memberValues: DashboardMetricMemberPoint[]; +} + +export interface DashboardHeadlineMetric { + id: "sessions" | "uncommitted" | "commitRate"; + label: string; + valueLabel: string; + deltaLabel: string; + deltaTone: DashboardDeltaTone; + description: string; +} + +export interface DashboardDailyPatternPoint { + date: string; + axisLabel: string; + fullLabel: string; + commits: number | null; + sessions: number | null; + commitRate: number | null; +} + +export interface DashboardRankedOutputRow { + label: string; + commits: number; + sessions: number; + commitRate: number; + secondaryLabel?: string; +} + +export interface DashboardDistributionRow { + label: string; + commits: number; + sessions: number; + commitRate: number; + sharePercent: number; +} + +export interface DashboardProfileComparisonRow { + label: string; + committed: string; + uncommitted: string; +} + +export interface DashboardBinaryImpact { + label: string; + withLabel: string; + withValue: string; + withoutLabel: string; + withoutValue: string; + description: string; +} + +export interface DashboardCommitCostMetric { + label: string; + valueLabel: string; + description: string; +} + +export interface DashboardBranchActivity { + repository: string; + branch: string; + commits: number; + players: number; +} + +export interface DashboardOutputSnapshot { + headlineMetrics: DashboardHeadlineMetric[]; + dailyPattern: DashboardDailyPatternPoint[]; + players: DashboardRankedOutputRow[]; + repositories: DashboardRankedOutputRow[]; + models: DashboardDistributionRow[]; + sources: DashboardDistributionRow[]; + sessionProfile: DashboardProfileComparisonRow[]; + impactComparisons: DashboardBinaryImpact[]; + commitCostMetrics: DashboardCommitCostMetric[]; + activeBranches: DashboardBranchActivity[]; + reposTouched: number; +} + +const dashboardMetricTemplates: Array<{ + id: DashboardMetricId; + label: string; + value: number; + deltaLabel: string; + deltaTone: DashboardDeltaTone; + weeklyValues: Array; + memberValues: Array; +}> = [ + { + id: "output", + label: "Output", + value: 89, + deltaLabel: "+12", + deltaTone: "positive", + weeklyValues: [100, 83, 57, 83, null, null, null], + memberValues: [92, 84, 77, 74, 69, 58, 41, 18], + }, + { + id: "quality", + label: "Quality", + value: 72, + deltaLabel: "0", + deltaTone: "neutral", + weeklyValues: [82, 76, 80, 79, 77, 81, 78], + memberValues: [86, 81, 78, 83, 72, 61, 74, 57], + }, + { + id: "efficiency", + label: "Efficiency", + value: 68, + deltaLabel: "-5", + deltaTone: "negative", + weeklyValues: [74, 71, 67, 69, 61, null, null], + memberValues: [79, 74, 68, 71, 66, 57, 62, 49], + }, + { + id: "speed", + label: "Speed", + value: 70, + deltaLabel: "+2", + deltaTone: "positive", + weeklyValues: [85, 79, 76, 81, 73, 69, 67], + memberValues: [88, 79, 73, 76, 71, 63, 59, 46], + }, + { + id: "craft", + label: "Craft", + value: 62, + deltaLabel: "+4", + deltaTone: "positive", + weeklyValues: [64, 58, 55, 67, 61, null, null], + memberValues: [72, 66, 61, 68, 59, 53, 49, 39], + }, + { + id: "consistency", + label: "Consistency", + value: 67, + deltaLabel: "+1", + deltaTone: "positive", + weeklyValues: [88, 86, 84, 83, 80, 79, 78], + memberValues: [90, 84, 79, 82, 76, 68, 52, 34], + }, +]; + +const dashboardMetricDetailTemplates: Record< + DashboardMetricId, + { + grouped: { + primaryLabel: string; + secondaryLabel: string; + primaryValues: Array; + secondaryValues: Array; + }; + single: { + label: string; + values: Array; + }; + } +> = { + output: { + grouped: { + primaryLabel: "commits", + secondaryLabel: "sessions", + primaryValues: [19, 16, 11, 18, null, null, null], + secondaryValues: [44, 38, 26, 41, null, null, null], + }, + single: { + label: "lines of code", + values: [1280, 1045, 690, 1175, null, null, null], + }, + }, + quality: { + grouped: { + primaryLabel: "reviews", + secondaryLabel: "fixes", + primaryValues: [12, 14, 13, 15, 11, 10, 12], + secondaryValues: [6, 5, 7, 6, 5, 4, 5], + }, + single: { + label: "confidence points", + values: [118, 109, 102, 120, 96, 91, 94], + }, + }, + efficiency: { + grouped: { + primaryLabel: "tasks", + secondaryLabel: "handoffs", + primaryValues: [21, 20, 18, 22, 16, null, null], + secondaryValues: [9, 8, 7, 10, 6, null, null], + }, + single: { + label: "focus minutes", + values: [96, 88, 81, 93, 76, null, null], + }, + }, + speed: { + grouped: { + primaryLabel: "patches", + secondaryLabel: "revisions", + primaryValues: [18, 16, 15, 17, 13, 12, 11], + secondaryValues: [7, 6, 5, 7, 5, 4, 4], + }, + single: { + label: "time saved", + values: [142, 130, 126, 138, 116, 104, 98], + }, + }, + craft: { + grouped: { + primaryLabel: "refactors", + secondaryLabel: "polish", + primaryValues: [9, 8, 7, 10, 8, null, null], + secondaryValues: [11, 10, 9, 12, 10, null, null], + }, + single: { + label: "cleanup lines", + values: [640, 590, 510, 690, 560, null, null], + }, + }, + consistency: { + grouped: { + primaryLabel: "streaks", + secondaryLabel: "check-ins", + primaryValues: [5, 5, 4, 5, 4, 4, 4], + secondaryValues: [13, 12, 11, 13, 10, 10, 9], + }, + single: { + label: "stable sessions", + values: [84, 81, 79, 82, 76, 74, 72], + }, + }, +}; + +const headlineMetricsTemplate: DashboardHeadlineMetric[] = [ + { + id: "sessions", + label: "Sessions run", + valueLabel: "142", + deltaLabel: "-6", + deltaTone: "negative", + description: "Total AI sessions this period.", + }, + { + id: "uncommitted", + label: "Uncommitted sessions", + valueLabel: "53", + deltaLabel: "+8", + deltaTone: "negative", + description: "Sessions that did not produce a commit.", + }, + { + id: "commitRate", + label: "Commit rate", + valueLabel: "63%", + deltaLabel: "+4.6 pp", + deltaTone: "positive", + description: "Sessions that produced a commit.", + }, +]; + +const dailyPatternTemplate = [ + { commits: 17, sessions: 27, commitRate: 63 }, + { commits: 14, sessions: 22, commitRate: 64 }, + { commits: 18, sessions: 29, commitRate: 62 }, + { commits: 21, sessions: 34, commitRate: 62 }, + { commits: 19, sessions: 30, commitRate: 63 }, + { commits: null, sessions: null, commitRate: null }, + { commits: null, sessions: null, commitRate: null }, +] as const; + +const playersTemplate: DashboardRankedOutputRow[] = [ + { label: "Morgan Lee", commits: 21, sessions: 28, commitRate: 75 }, + { label: "Riley Nguyen", commits: 17, sessions: 25, commitRate: 68 }, + { label: "Taylor Chen", commits: 14, sessions: 23, commitRate: 61 }, + { label: "Alex Kim", commits: 13, sessions: 18, commitRate: 72 }, + { label: "Jordan Rivera", commits: 12, sessions: 20, commitRate: 60 }, + { label: "Sam Park", commits: 8, sessions: 18, commitRate: 44 }, + { label: "Drew Wilson", commits: 4, sessions: 6, commitRate: 67 }, + { label: "Casey Patel", commits: 0, sessions: 4, commitRate: 0 }, +]; + +const repositoriesTemplate: DashboardRankedOutputRow[] = [ + { label: "payments", commits: 28, sessions: 37, commitRate: 76 }, + { label: "dashboard", commits: 19, sessions: 31, commitRate: 61 }, + { label: "conductor", commits: 16, sessions: 29, commitRate: 55 }, + { label: "tel-aviv-v1", commits: 14, sessions: 21, commitRate: 67 }, + { label: "docs-site", commits: 12, sessions: 24, commitRate: 50 }, +]; + +const modelsTemplate: DashboardDistributionRow[] = [ + { + label: "Opus", + commits: 48, + sessions: 83, + commitRate: 58, + sharePercent: 54, + }, + { + label: "Sonnet 4", + commits: 36, + sessions: 51, + commitRate: 71, + sharePercent: 40, + }, + { label: "Haiku", commits: 5, sessions: 8, commitRate: 63, sharePercent: 6 }, +]; + +const sourcesTemplate: DashboardDistributionRow[] = [ + { + label: "Claude", + commits: 66, + sessions: 98, + commitRate: 67, + sharePercent: 74, + }, + { + label: "Codex", + commits: 23, + sessions: 44, + commitRate: 52, + sharePercent: 26, + }, +]; + +const sessionProfileTemplate: DashboardProfileComparisonRow[] = [ + { label: "Avg duration", committed: "22 min", uncommitted: "18 min" }, + { label: "Avg interactions", committed: "9.2", uncommitted: "6.8" }, + { label: "Avg errors", committed: "0.8", uncommitted: "1.6" }, + { label: "Avg tokens", committed: "84k", uncommitted: "66k" }, + { label: "Avg cost", committed: "$3.19", uncommitted: "$2.41" }, + { label: "Most common model", committed: "Sonnet 4", uncommitted: "Opus" }, + { label: "Plan mode usage", committed: "58%", uncommitted: "31%" }, +]; + +const impactComparisonsTemplate: DashboardBinaryImpact[] = [ + { + label: "Plan mode impact", + withLabel: "Plan mode on", + withValue: "74%", + withoutLabel: "Plan mode off", + withoutValue: "56%", + description: "Commit rate for sessions with planning vs without.", + }, + { + label: "Subagent impact", + withLabel: "Subagents on", + withValue: "68%", + withoutLabel: "Subagents off", + withoutValue: "61%", + description: "Commit rate for sessions that used subagents.", + }, +]; + +const commitCostMetricsTemplate: DashboardCommitCostMetric[] = [ + { + label: "Time to commit", + valueLabel: "22m", + description: "14m human thinking / 8m AI inference.", + }, + { + label: "Interactions to commit", + valueLabel: "9.2", + description: "Average turns in sessions that shipped code.", + }, + { + label: "Cost per commit", + valueLabel: "$3.19", + description: "Committed session spend divided by shipped commits.", + }, +]; + +const activeBranchesTemplate: DashboardBranchActivity[] = [ + { + repository: "payments", + branch: "feature/stripe-v3", + commits: 8, + players: 3, + }, + { + repository: "dashboard", + branch: "feature/output-overview", + commits: 6, + players: 2, + }, + { + repository: "conductor", + branch: "fix/sidebar-reflow", + commits: 5, + players: 2, + }, + { + repository: "tel-aviv-v1", + branch: "feature/dashboard-preview", + commits: 4, + players: 1, + }, + { + repository: "docs-site", + branch: "content/agent-skills", + commits: 3, + players: 2, + }, +]; + +function resolveWeekStart(endDate: string) { + const parsedEndDate = parseISO(endDate); + + if (Number.isNaN(parsedEndDate.getTime())) { + return startOfWeek(new Date(), { weekStartsOn: 1 }); + } + + return startOfWeek(parsedEndDate, { weekStartsOn: 1 }); +} + +function isValidDate(value: Date) { + return !Number.isNaN(value.getTime()); +} + +function resolveDateInterval(startDate: string, endDate: string) { + const parsedStartDate = parseISO(startDate); + const parsedEndDate = parseISO(endDate); + + if (!isValidDate(parsedStartDate) && !isValidDate(parsedEndDate)) { + const weekStart = resolveWeekStart(endDate); + return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)); + } + + const safeEndDate = isValidDate(parsedEndDate) + ? parsedEndDate + : parsedStartDate; + const safeStartDate = isValidDate(parsedStartDate) + ? parsedStartDate + : addDays(safeEndDate, -6); + + const [intervalStart, intervalEnd] = + safeStartDate.getTime() <= safeEndDate.getTime() + ? [safeStartDate, safeEndDate] + : [safeEndDate, safeStartDate]; + + return eachDayOfInterval({ + start: intervalStart, + end: intervalEnd, + }); +} + +function buildDailyPatternPoint( + date: Date, + index: number, +): DashboardDailyPatternPoint { + const template = dailyPatternTemplate[getISODay(date) - 1]; + + if ( + template.commits == null || + template.sessions == null || + template.commitRate == null + ) { + return { + date: format(date, "yyyy-MM-dd"), + axisLabel: format(date, "EEE"), + fullLabel: format(date, "EEEE, MMM d"), + commits: null, + sessions: null, + commitRate: null, + }; + } + + const weekOffset = Math.floor(index / 7); + const variabilitySeed = date.getDate() + date.getMonth() * 3 + weekOffset * 2; + const variability = (variabilitySeed % 5) - 2; + const sessions = Math.max( + template.commits, + Math.round(template.sessions * (1 + variability * 0.06)), + ); + const commitRate = Math.max( + 0, + Math.min(100, Math.round(template.commitRate + variability * 2)), + ); + const commits = Math.min( + sessions, + Math.max(0, Math.round((sessions * commitRate) / 100)), + ); + + return { + date: format(date, "yyyy-MM-dd"), + axisLabel: format(date, "EEE"), + fullLabel: format(date, "EEEE, MMM d"), + commits, + sessions, + commitRate, + }; +} + +function buildHeadlineMetrics(dailyPattern: DashboardDailyPatternPoint[]) { + const commits = dailyPattern.reduce( + (total, point) => total + (point.commits ?? 0), + 0, + ); + const sessions = dailyPattern.reduce( + (total, point) => total + (point.sessions ?? 0), + 0, + ); + const uncommitted = Math.max(sessions - commits, 0); + const commitRate = sessions > 0 ? Math.round((commits / sessions) * 100) : 0; + + return headlineMetricsTemplate.map((metric) => { + if (metric.id === "uncommitted") { + return { + ...metric, + valueLabel: `${uncommitted}`, + }; + } + + if (metric.id === "sessions") { + return { + ...metric, + valueLabel: `${sessions}`, + }; + } + + return { + ...metric, + valueLabel: `${commitRate}%`, + }; + }); +} + +function clampDashboardScore(value: number) { + return Math.max(0, Math.min(100, value)); +} + +function getRangeSeed(interval: Date[]) { + return interval.reduce( + (total, date, index) => + total + date.getDate() * (index + 3) + (date.getMonth() + 1) * 11, + 0, + ); +} + +function getMetricRangeBias(metricId: DashboardMetricId, dayCount: number) { + switch (metricId) { + case "output": + return dayCount >= 21 ? 4 : dayCount <= 3 ? -5 : 0; + case "quality": + return dayCount >= 14 ? 1 : 0; + case "efficiency": + return dayCount >= 21 ? 2 : dayCount <= 3 ? -3 : 0; + case "speed": + return dayCount <= 3 ? 4 : dayCount >= 21 ? -1 : 1; + case "craft": + return dayCount >= 14 ? 2 : 0; + case "consistency": + return dayCount >= 21 ? 6 : dayCount >= 14 ? 3 : -2; + } +} + +function buildMetricMemberValues( + metricId: DashboardMetricId, + memberValues: Array, + startDate: string, + endDate: string, +) { + const interval = resolveDateInterval(startDate, endDate); + const dayCount = interval.length; + const rangeSeed = getRangeSeed(interval); + const volatility = + dayCount <= 3 ? 8 : dayCount <= 7 ? 6 : dayCount <= 21 ? 4 : 2; + + return playersTemplate.map((player, index) => { + const baseValue = memberValues[index] ?? null; + + if (baseValue == null) { + return { + label: player.label, + value: null, + }; + } + + const activityModifier = 0.88 + (((rangeSeed + index * 19) % 9) - 4) * 0.04; + const expectedSessions = + (player.sessions / 7) * dayCount * activityModifier; + + if (expectedSessions < 1.35) { + return { + label: player.label, + value: null, + }; + } + + const waveform = + ((((rangeSeed + (index + 1) * 17 + metricId.length * 13) % 11) - 5) * + volatility) / + 5; + const playerBias = + player.commitRate >= 70 ? 2 : player.commitRate <= 50 ? -3 : 0; + const rangeBias = getMetricRangeBias(metricId, dayCount); + + return { + label: player.label, + value: clampDashboardScore( + Math.round(baseValue + waveform + playerBias + rangeBias), + ), + }; + }); +} + +export function createDashboardMetrics( + startDate: string, + endDate: string, +): DashboardMetric[] { + const weekStart = resolveWeekStart(endDate); + + return dashboardMetricTemplates.map((metric) => ({ + id: metric.id, + label: metric.label, + value: metric.value, + deltaLabel: metric.deltaLabel, + deltaTone: metric.deltaTone, + trend: metric.weeklyValues.map((value, index) => ({ + date: format(addDays(weekStart, index), "yyyy-MM-dd"), + value, + })), + memberValues: buildMetricMemberValues( + metric.id, + metric.memberValues, + startDate, + endDate, + ), + })); +} + +export function createDashboardMetricDetail( + metricId: DashboardMetricId, + endDate: string, +): DashboardMetricDetailData { + const weekStart = resolveWeekStart(endDate); + const template = dashboardMetricDetailTemplates[metricId]; + + return { + grouped: { + primaryLabel: template.grouped.primaryLabel, + secondaryLabel: template.grouped.secondaryLabel, + points: Array.from({ length: 7 }, (_, index) => ({ + date: format(addDays(weekStart, index), "yyyy-MM-dd"), + primary: template.grouped.primaryValues[index] ?? null, + secondary: template.grouped.secondaryValues[index] ?? null, + })), + }, + single: { + label: template.single.label, + points: Array.from({ length: 7 }, (_, index) => ({ + date: format(addDays(weekStart, index), "yyyy-MM-dd"), + value: template.single.values[index] ?? null, + })), + }, + }; +} + +export function createDashboardOutputSnapshot( + startDate: string, + endDate: string, +): DashboardOutputSnapshot { + const dailyPattern = resolveDateInterval(startDate, endDate).map( + buildDailyPatternPoint, + ); + + return { + headlineMetrics: buildHeadlineMetrics(dailyPattern), + dailyPattern, + players: playersTemplate, + repositories: repositoriesTemplate, + models: modelsTemplate, + sources: sourcesTemplate, + sessionProfile: sessionProfileTemplate, + impactComparisons: impactComparisonsTemplate, + commitCostMetrics: commitCostMetricsTemplate, + activeBranches: activeBranchesTemplate, + reposTouched: 7, + }; +} + +export const dashboardUserOptions = [ + "Morgan Lee", + "Riley Nguyen", + "Taylor Chen", + "Alex Kim", + "Jordan Rivera", + "Sam Park", + "Drew Wilson", + "Casey Patel", +] as const; diff --git a/apps/web/src/features/dashboard/use-dashboard-home-data.ts b/apps/web/src/features/dashboard/use-dashboard-home-data.ts new file mode 100644 index 00000000..c24ac5ce --- /dev/null +++ b/apps/web/src/features/dashboard/use-dashboard-home-data.ts @@ -0,0 +1,81 @@ +import { useMemo } from "react"; +import { useDateRange } from "@/contexts/DateRangeContext"; +import { useOrganization } from "@/contexts/OrganizationContext"; +import { buildDashboardPerformanceUsers } from "@/features/dashboard/data/dashboard-performance-adapter"; +import { mergeDashboardSnapshotWithRoi } from "@/features/dashboard/data/dashboard-roi-adapter"; +import { createDashboardOutputSnapshot } from "@/features/dashboard/data/dashboard-static-data"; +import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery"; +import { useFullOrganization } from "@/hooks/useFullOrganization"; +import { orpc } from "@/lib/orpc"; + +export function useDashboardHomeData() { + const { activeOrg } = useOrganization(); + const { endDate, startDate } = useDateRange(); + const { data: fullOrganization } = useFullOrganization(activeOrg?.id); + const roiDashboardQuery = useAnalyticsQuery( + orpc.analytics.roi.dashboard.queryOptions({ + input: { startDate, endDate }, + }), + ); + const usersTokenUsageQuery = useAnalyticsQuery( + orpc.analytics.overview.usersTokenUsage.queryOptions({ + input: { startDate, endDate }, + }), + ); + const usersDailyTrendQuery = useAnalyticsQuery( + orpc.analytics.overview.usersDailyTrend.queryOptions({ + input: { startDate, endDate }, + }), + ); + const repositoriesDailyTrendQuery = useAnalyticsQuery( + orpc.analytics.overview.repositoriesDailyTrend.queryOptions({ + input: { startDate, endDate }, + }), + ); + + const userImageById = useMemo( + () => + new Map( + (fullOrganization?.members ?? []).map((member) => [ + member.userId, + member.user.image, + ]), + ), + [fullOrganization?.members], + ); + + const performanceUsers = useMemo( + () => + buildDashboardPerformanceUsers( + usersTokenUsageQuery.data, + usersDailyTrendQuery.data, + userImageById, + fullOrganization?.members ?? [], + ), + [ + fullOrganization?.members, + userImageById, + usersDailyTrendQuery.data, + usersTokenUsageQuery.data, + ], + ); + + const snapshot = useMemo(() => { + const baseSnapshot = createDashboardOutputSnapshot(startDate, endDate); + + return mergeDashboardSnapshotWithRoi(baseSnapshot, roiDashboardQuery.data); + }, [endDate, roiDashboardQuery.data, startDate]); + + return { + endDate, + isDashboardSnapshotPending: roiDashboardQuery.isPending, + isPerformanceChartPending: + usersTokenUsageQuery.isPending || usersDailyTrendQuery.isPending, + isRepositoryChartPending: repositoriesDailyTrendQuery.isPending, + performanceUserDailyTrend: usersDailyTrendQuery.data, + performanceUsers, + repositoryDailyTrend: repositoriesDailyTrendQuery.data, + snapshot, + startDate, + }; +} diff --git a/apps/web/src/layouts/DashboardLayout.tsx b/apps/web/src/features/shell/AppShellLayout.tsx similarity index 54% rename from apps/web/src/layouts/DashboardLayout.tsx rename to apps/web/src/features/shell/AppShellLayout.tsx index 42857f40..24d58499 100644 --- a/apps/web/src/layouts/DashboardLayout.tsx +++ b/apps/web/src/features/shell/AppShellLayout.tsx @@ -1,13 +1,13 @@ import { Outlet } from "react-router-dom"; import { Toaster } from "sonner"; -import { Breadcrumb } from "../components/analytics/Breadcrumb"; -import { Sidebar } from "../components/analytics/Sidebar"; -import { ChatwootBootstrap } from "../components/support/ChatwootBootstrap"; -import { DateRangeProvider } from "../contexts/DateRangeContext"; -import { FilterProvider } from "../contexts/FilterContext"; -import { OrganizationProvider } from "../contexts/OrganizationContext"; +import { ChatwootBootstrap } from "@/components/support/ChatwootBootstrap"; +import { DateRangeProvider } from "@/contexts/DateRangeContext"; +import { FilterProvider } from "@/contexts/FilterContext"; +import { OrganizationProvider } from "@/contexts/OrganizationContext"; +import { AppSidebar } from "@/features/shell/components/AppSidebar"; +import { SiteHeader } from "@/features/shell/components/SiteHeader"; -export function DashboardLayout() { +export function AppShellLayout() { return ( @@ -15,9 +15,9 @@ export function DashboardLayout() {
- +
- +
diff --git a/apps/web/src/components/analytics/Sidebar.tsx b/apps/web/src/features/shell/components/AppSidebar.tsx similarity index 51% rename from apps/web/src/components/analytics/Sidebar.tsx rename to apps/web/src/features/shell/components/AppSidebar.tsx index b5647581..b7909bc4 100644 --- a/apps/web/src/components/analytics/Sidebar.tsx +++ b/apps/web/src/features/shell/components/AppSidebar.tsx @@ -1,60 +1,48 @@ import { - AlertCircle, - BookOpen, Building2, Check, ChevronsLeft, ChevronsRight, ChevronsUpDown, - Clock, - DollarSign, - FolderKanban, - LayoutDashboard, LogOut, Mail, Plus, Settings, Shield, - UserCircle, } from "lucide-react"; import { useTheme } from "next-themes"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { Link, useLocation } from "react-router-dom"; -import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics"; -import { useOrganization } from "../../contexts/OrganizationContext"; -import { useUserInvitations } from "../../hooks/useUserInvitations"; -import { authClient, signOut } from "../../lib/auth-client"; -import { getAnalyticsPageName } from "../../lib/product-analytics"; -import { cn } from "../../lib/utils"; -import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { ThemeToggle } from "@/components/analytics/ThemeToggle"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "../ui/dropdown-menu"; +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from "../ui/tooltip"; -import { ThemeToggle } from "./ThemeToggle"; +} from "@/components/ui/tooltip"; +import { useOrganization } from "@/contexts/OrganizationContext"; +import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics"; +import { useUserInvitations } from "@/hooks/useUserInvitations"; +import { authClient, signOut } from "@/lib/auth-client"; +import { getAnalyticsPageName } from "@/lib/product-analytics"; +import { cn } from "@/lib/utils"; +import { + isShellRouteActive, + primaryShellRoutes, + type ShellRouteDefinition, +} from "../config/shell-routes"; -const navigation = [ - { name: "Overview", href: "/dashboard", icon: LayoutDashboard }, - { name: "Developers", href: "/dashboard/developers", icon: UserCircle }, - { name: "Projects", href: "/dashboard/projects", icon: FolderKanban }, - { name: "Sessions", href: "/dashboard/sessions", icon: Clock }, - { name: "Learnings", href: "/dashboard/learnings", icon: BookOpen }, - { name: "Errors", href: "/dashboard/errors", icon: AlertCircle }, - { - name: "ROI Calculator", - href: "/dashboard/roi", - icon: DollarSign, - }, -]; +const ADMIN_ORGANIZATION_ID = ( + import.meta.env.VITE_ADMIN_ORGANIZATION_ID ?? "" +).trim(); function getInitials(name: string) { return name @@ -65,11 +53,68 @@ function getInitials(name: string) { .slice(0, 2); } +function SidebarNavLink({ + badgeLabel, + collapsed, + isActive, + label, + onClick, + to, + icon, +}: { + badgeLabel?: string; + collapsed: boolean; + isActive: boolean; + label: string; + onClick: () => void; + to: string; + icon: ReactNode; +}) { + const link = ( + + + {icon} + {badgeLabel ? ( + + {badgeLabel} + + ) : null} + + {collapsed ? null : ( + {label} + )} + + ); + + if (!collapsed) { + return link; + } + + return ( + + {link} + + {badgeLabel ? `${label} (${badgeLabel})` : label} + + + ); +} + function OrgSwitcher({ collapsed }: { collapsed: boolean }) { const { activeOrg, organizations, switchOrg } = useOrganization(); const { trackNavigation, trackOrganizationAction } = useAnalyticsTracking(); - const handleSelect = async (orgId: string) => { + async function handleSelect(orgId: string) { if (orgId === activeOrg?.id) { return; } @@ -80,8 +125,9 @@ function OrgSwitcher({ collapsed }: { collapsed: boolean }) { sourceComponent: "org_switcher", targetId: orgId, }); + await switchOrg(orgId); - }; + } return ( @@ -89,12 +135,12 @@ function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
-
+ ) : null} + ); } diff --git a/apps/web/src/components/analytics/Breadcrumb.tsx b/apps/web/src/features/shell/components/SiteHeader.tsx similarity index 66% rename from apps/web/src/components/analytics/Breadcrumb.tsx rename to apps/web/src/features/shell/components/SiteHeader.tsx index 52dc56b4..b73fca8b 100644 --- a/apps/web/src/components/analytics/Breadcrumb.tsx +++ b/apps/web/src/features/shell/components/SiteHeader.tsx @@ -14,32 +14,37 @@ const segmentLabels: Record = { errors: "Errors", invitations: "Invitations", profile: "Profile", + organization: "Organization", + admin: "Admin", + new: "Create organization", }; -export function Breadcrumb() { +export function SiteHeader() { const { pathname } = useLocation(); - const segments = pathname.split("/").filter(Boolean); const { trackNavigation } = useAnalyticsTracking(); - const { userMap } = useUserMap(); + const segments = pathname.split("/").filter(Boolean); const crumbs = segments.map((segment, index) => { const href = `/${segments.slice(0, index + 1).join("/")}`; - const prevSegment = index > 0 ? segments[index - 1] : null; - const isDeveloperUserId = prevSegment === "developers"; - const label = isDeveloperUserId + const previousSegment = index > 0 ? segments[index - 1] : null; + const isDeveloperSegment = previousSegment === "developers"; + const label = isDeveloperSegment ? formatUsername(segment, userMap) - : segmentLabels[segment] || decodeURIComponent(segment); - const isLast = index === segments.length - 1; + : (segmentLabels[segment] ?? decodeURIComponent(segment)); - return { href, label, isLast }; + return { + href, + label, + isLast: index === segments.length - 1, + }; }); return ( -