Skip to content

Commit 37b08d7

Browse files
authored
Merge pull request #49 from VectorInstitute/add_aggregate_view
Add aggregate view by company and not just team
2 parents 0a9057a + 52ff3bb commit 37b08d7

File tree

3 files changed

+181
-16
lines changed

3 files changed

+181
-16
lines changed

services/analytics/app/template/[templateName]/template-teams-content.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ArrowLeft, Users, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
66
import type { User } from '@vector-institute/aieng-auth-core';
77
import type { AnalyticsSnapshot, TeamMetrics } from '@/lib/types';
88
import { Tooltip } from '@/app/components/tooltip';
9+
import { aggregateByCompany } from '@/lib/company-utils';
910

1011
type SortColumn = 'team_name' | 'workspaces_for_template' | 'unique_active_users' | 'total_workspace_hours' | 'total_active_hours' | 'active_days';
1112
type SortDirection = 'asc' | 'desc';
@@ -22,6 +23,7 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
2223
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
2324
const [sortColumn, setSortColumn] = useState<SortColumn>('workspaces_for_template');
2425
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
26+
const [viewMode, setViewMode] = useState<'teams' | 'companies'>('teams');
2527

2628
const handleLogout = async () => {
2729
try {
@@ -73,9 +75,17 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
7375
.filter(team => team.workspaces_for_template > 0);
7476
}, [data, template]);
7577

78+
// Apply company aggregation based on view mode
79+
const displayTeams = useMemo(() => {
80+
if (viewMode === 'companies') {
81+
return aggregateByCompany(templateTeams);
82+
}
83+
return templateTeams;
84+
}, [templateTeams, viewMode]);
85+
7686
// Sort template teams
7787
const sortedTemplateTeams = useMemo(() => {
78-
const sorted = [...templateTeams].sort((a, b) => {
88+
const sorted = [...displayTeams].sort((a, b) => {
7989
let aValue: string | number = a[sortColumn];
8090
let bValue: string | number = b[sortColumn];
8191

@@ -97,7 +107,7 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
97107
});
98108

99109
return sorted;
100-
}, [templateTeams, sortColumn, sortDirection]);
110+
}, [displayTeams, sortColumn, sortDirection]);
101111

102112
// Handle column header click
103113
const handleSort = (column: SortColumn) => {
@@ -252,13 +262,42 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
252262
<div className="animate-slide-up">
253263
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl border-2 border-slate-200 dark:border-slate-700 overflow-hidden">
254264
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-700">
255-
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
256-
<Users className="h-6 w-6 text-vector-magenta" />
257-
Teams Using This Template
258-
</h2>
259-
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
260-
{sortedTemplateTeams.length} teams have created workspaces from this template
261-
</p>
265+
<div className="flex items-center justify-between">
266+
<div>
267+
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
268+
<Users className="h-6 w-6 text-vector-magenta" />
269+
Teams Using This Template
270+
</h2>
271+
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
272+
{viewMode === 'companies'
273+
? `${sortedTemplateTeams.length} companies have created workspaces from this template`
274+
: `${sortedTemplateTeams.length} teams have created workspaces from this template`
275+
}
276+
</p>
277+
</div>
278+
<div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
279+
<button
280+
onClick={() => setViewMode('teams')}
281+
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${
282+
viewMode === 'teams'
283+
? 'bg-white dark:bg-slate-700 text-vector-magenta shadow-sm'
284+
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
285+
}`}
286+
>
287+
Individual Teams
288+
</button>
289+
<button
290+
onClick={() => setViewMode('companies')}
291+
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${
292+
viewMode === 'companies'
293+
? 'bg-white dark:bg-slate-700 text-vector-magenta shadow-sm'
294+
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
295+
}`}
296+
>
297+
By Company
298+
</button>
299+
</div>
300+
</div>
262301
</div>
263302

264303
<div className="overflow-x-auto">
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { TeamMetrics, ActivityStatus } from './types';
2+
3+
/**
4+
* Member data for aggregation
5+
*/
6+
interface MemberData {
7+
github_handle: string;
8+
name: string;
9+
workspace_count: number;
10+
last_active: string;
11+
activity_status: ActivityStatus;
12+
}
13+
14+
/**
15+
* Extract company name from team name
16+
*
17+
* @param teamName - Full team name (e.g., "scotiabank-2-tangerine", "bell-1")
18+
* @returns Company name (e.g., "scotiabank", "bell")
19+
*
20+
* @example
21+
* extractCompanyName("scotiabank-2-tangerine") // "scotiabank"
22+
* extractCompanyName("bell-1") // "bell"
23+
* extractCompanyName("hitachi-rail-1") // "hitachi-rail"
24+
* extractCompanyName("facilitators") // "facilitators"
25+
*/
26+
export function extractCompanyName(teamName: string): string {
27+
// Special cases: teams without numeric suffix
28+
if (teamName === 'facilitators' || teamName === 'Unassigned') {
29+
return teamName;
30+
}
31+
32+
// Extract prefix before first "-{digit}" pattern
33+
// Handles: "scotiabank-2-tangerine", "bell-1", "hitachi-rail-1"
34+
const match = teamName.match(/^([a-z-]+?)-(\d+)/);
35+
return match ? match[1] : teamName;
36+
}
37+
38+
/**
39+
* Aggregate teams by company
40+
* Groups individual teams (e.g., "bell-1", "bell-2") into company-level aggregates (e.g., "bell")
41+
* Follows pattern from lib/metrics.ts:aggregateByTeam()
42+
*
43+
* @param teams - Array of individual team metrics with workspaces_for_template count
44+
* @returns Array of company-aggregated metrics
45+
*
46+
* Aggregation rules:
47+
* - Sum numeric metrics (workspaces, hours, active_days)
48+
* - Deduplicate members by github_handle, keeping most recent activity
49+
* - Sum workspace_count across team instances for each member
50+
* - Merge template distributions
51+
* - Calculate active_days as union of all dates (not sum)
52+
*/
53+
export function aggregateByCompany(
54+
teams: (TeamMetrics & { workspaces_for_template: number })[]
55+
): (TeamMetrics & { workspaces_for_template: number })[] {
56+
// Group teams by company using Map
57+
const companyMap = new Map<string, (TeamMetrics & { workspaces_for_template: number })[]>();
58+
59+
teams.forEach(team => {
60+
const company = extractCompanyName(team.team_name);
61+
const companyTeams = companyMap.get(company) || [];
62+
companyTeams.push(team);
63+
companyMap.set(company, companyTeams);
64+
});
65+
66+
// Aggregate each company
67+
return Array.from(companyMap.entries()).map(([companyName, companyTeams]) => {
68+
// Sum numeric metrics
69+
const total_workspaces = companyTeams.reduce((sum, t) => sum + t.total_workspaces, 0);
70+
const total_workspace_hours = companyTeams.reduce((sum, t) => sum + t.total_workspace_hours, 0);
71+
const total_active_hours = companyTeams.reduce((sum, t) => sum + t.total_active_hours, 0);
72+
const workspaces_for_template = companyTeams.reduce((sum, t) => sum + t.workspaces_for_template, 0);
73+
const avg_workspace_hours = total_workspaces > 0 ? total_workspace_hours / total_workspaces : 0;
74+
75+
// Deduplicate members by github_handle, keep most recent activity
76+
const memberMap = new Map<string, MemberData>();
77+
companyTeams.forEach(team => {
78+
team.members.forEach(member => {
79+
const existing = memberMap.get(member.github_handle);
80+
if (!existing || new Date(member.last_active) > new Date(existing.last_active)) {
81+
// Keep most recent, but sum workspace_count
82+
memberMap.set(member.github_handle, {
83+
...member,
84+
workspace_count: (existing?.workspace_count || 0) + member.workspace_count
85+
});
86+
}
87+
});
88+
});
89+
90+
const members = Array.from(memberMap.values())
91+
.sort((a, b) => new Date(b.last_active).getTime() - new Date(a.last_active).getTime());
92+
93+
// Count unique active users
94+
const unique_active_users = members.filter(m => m.activity_status === 'active').length;
95+
96+
// Merge template distributions
97+
const template_distribution: Record<string, number> = {};
98+
companyTeams.forEach(team => {
99+
Object.entries(team.template_distribution).forEach(([template, count]) => {
100+
template_distribution[template] = (template_distribution[template] || 0) + count;
101+
});
102+
});
103+
104+
// Calculate active days as union of all dates
105+
const activeDates = new Set<string>();
106+
companyTeams.forEach(team => {
107+
team.members.forEach(member => {
108+
const date = new Date(member.last_active).toISOString().split('T')[0];
109+
activeDates.add(date);
110+
});
111+
});
112+
113+
return {
114+
team_name: companyName,
115+
total_workspaces,
116+
unique_active_users,
117+
total_workspace_hours: Math.round(total_workspace_hours),
118+
total_active_hours: Math.round(total_active_hours),
119+
avg_workspace_hours: Math.round(avg_workspace_hours * 10) / 10,
120+
active_days: activeDates.size,
121+
workspaces_for_template,
122+
template_distribution,
123+
members
124+
};
125+
});
126+
}

services/analytics/package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)