From febd03ac631d9d0c02d20ca3e56a369c14533a7f Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 12:59:57 -0400 Subject: [PATCH 1/8] added /diagnostics page for installation / org list visibility --- backend/src/controllers/setup.controller.ts | 134 +++++ backend/src/routes/index.ts | 1 + backend/src/services/value_modeling_doc.md | 118 +++++ frontend/src/app/app.routes.ts | 2 + .../diagnostics/main-diagnostics.component.ts | 481 ++++++++++++++++++ frontend/src/app/main/main.component.html | 4 + .../src/app/services/api/setup.service.ts | 4 + 7 files changed, 744 insertions(+) create mode 100644 backend/src/services/value_modeling_doc.md create mode 100644 frontend/src/app/main/diagnostics/main-diagnostics.component.ts diff --git a/backend/src/controllers/setup.controller.ts b/backend/src/controllers/setup.controller.ts index e8abb580..1280b8ba 100644 --- a/backend/src/controllers/setup.controller.ts +++ b/backend/src/controllers/setup.controller.ts @@ -112,6 +112,140 @@ class SetupController { } } + async validateInstallations(req: Request, res: Response) { + try { + const diagnostics = { + timestamp: new Date().toISOString(), + appConnected: !!app.github.app, + totalInstallations: app.github.installations.length, + installations: [] as any[], + errors: [] as string[], + appInfo: null as any, + summary: { + validInstallations: 0, + invalidInstallations: 0, + organizationNames: [] as string[], + accountTypes: {} as Record + } + }; + + // Basic app validation + if (!app.github.app) { + diagnostics.errors.push('GitHub App is not initialized'); + return res.json(diagnostics); + } + + // Validate each installation + for (let i = 0; i < app.github.installations.length; i++) { + const { installation, octokit } = app.github.installations[i]; + + const installationDiag = { + index: i, + installationId: installation.id, + accountLogin: installation.account?.login || 'MISSING', + accountId: installation.account?.id || 'MISSING', + accountType: installation.account?.type || 'MISSING', + accountAvatarUrl: installation.account?.avatar_url || 'MISSING', + appId: installation.app_id, + appSlug: installation.app_slug, + targetType: installation.target_type, + permissions: installation.permissions, + events: installation.events, + createdAt: installation.created_at, + updatedAt: installation.updated_at, + suspendedAt: installation.suspended_at, + suspendedBy: installation.suspended_by, + hasOctokit: !!octokit, + octokitTest: null as any, + isValid: true, + validationErrors: [] as string[] + }; + + // Validate required fields + if (!installation.account?.login) { + installationDiag.isValid = false; + installationDiag.validationErrors.push('Missing account.login (organization name)'); + } + + if (!installation.account?.id) { + installationDiag.isValid = false; + installationDiag.validationErrors.push('Missing account.id'); + } + + if (!installation.account?.type) { + installationDiag.isValid = false; + installationDiag.validationErrors.push('Missing account.type'); + } + + // Test Octokit functionality + if (octokit) { + try { + // Test basic API call with the installation's octokit + const authTest = await octokit.rest.apps.getAuthenticated(); + installationDiag.octokitTest = { + success: true, + appName: authTest.data?.name || 'Unknown', + appOwner: (authTest.data?.owner as any)?.login || 'Unknown', + permissions: authTest.data?.permissions || {} + }; + } catch (error) { + installationDiag.octokitTest = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + installationDiag.isValid = false; + installationDiag.validationErrors.push(`Octokit API test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } else { + installationDiag.isValid = false; + installationDiag.validationErrors.push('Octokit instance is missing'); + } + + // Update summary + if (installationDiag.isValid) { + diagnostics.summary.validInstallations++; + if (installation.account?.login) { + diagnostics.summary.organizationNames.push(installation.account.login); + } + } else { + diagnostics.summary.invalidInstallations++; + } + + // Track account types + const accountType = installation.account?.type || 'Unknown'; + diagnostics.summary.accountTypes[accountType] = (diagnostics.summary.accountTypes[accountType] || 0) + 1; + + diagnostics.installations.push(installationDiag); + } + + // Additional app-level diagnostics + try { + const appInfo = await app.github.app.octokit.rest.apps.getAuthenticated(); + diagnostics.appInfo = { + name: appInfo.data?.name || 'Unknown', + description: appInfo.data?.description || 'No description', + owner: (appInfo.data?.owner as any)?.login || 'Unknown', + htmlUrl: appInfo.data?.html_url || 'Unknown', + permissions: appInfo.data?.permissions || {}, + events: appInfo.data?.events || [] + }; + } catch (error) { + diagnostics.errors.push(`Failed to get app info: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + // Sort organization names for easier reading + diagnostics.summary.organizationNames.sort(); + + res.json(diagnostics); + } catch (error) { + logger.error('Installation validation failed', error); + res.status(500).json({ + error: 'Installation validation failed', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + } diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index a31a9d1a..e977ce09 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -52,6 +52,7 @@ router.get('/setup/manifest', setupController.getManifest); router.post('/setup/existing-app', setupController.addExistingApp); router.post('/setup/db', setupController.setupDB); router.get('/setup/status', setupController.setupStatus); +router.get('/setup/validate-installations', setupController.validateInstallations); router.get('/status', setupController.getStatus); diff --git a/backend/src/services/value_modeling_doc.md b/backend/src/services/value_modeling_doc.md new file mode 100644 index 00000000..8c7d62c9 --- /dev/null +++ b/backend/src/services/value_modeling_doc.md @@ -0,0 +1,118 @@ +**Value Modeling & Targeting Documentation** + +This document outlines the rationale and logic behind the calculated metrics and targets used in the "Value Modeling & Targeting" dashboard. Each section corresponds to a category of metrics displayed in the dashboard. + +--- + +### Org Metrics + +**Seats** + +- **Logic**: Based on the average total active seats(licenses) across top 10 recent days for the organization. +- **Max**: Set to known total developer headcount. + +**Adopted Devs** + +- **Logic**: Average of total active developers using AI tooling (e.g. Copilot) from top 10 recent days for the organization. +- **Max**: Total known developer count. + +**Monthly Devs Reporting Time Savings** + +- **Logic**: Count of distinct users who responded to time-savings surveys in past 30 days. +- **Target**: Double the current, indicating intent to increase reporting. + +**% of Seats Reporting Time Savings** + +- **Logic**: (Monthly reporting users / total seats) \* 100. +- **Purpose**: Shows how broadly time savings are captured. + +**% of Seats Adopted** + +- **Logic**: (Adopted Devs / Total Seats) \* 100. +- **Use**: Adoption penetration relative to seat assignments. + +**% of Max Adopted** + +- **Logic**: (Adopted Devs / Total Developer Count) \* 100. +- **Use**: Indicates potential ceiling for adoption. + +--- + +### Daily User Metrics + +**Daily IDE Suggestions** + +- **Logic**: Averaged from last 5 valid daily records. +- **Target/Max**: Calibrated based on observed high-performing usage. + +**Daily IDE Acceptances** + +- **Logic**: Suggestions \* 30% (default assumed acceptance rate). +- **Target/Max**: Reflects healthy usage from productive orgs. + +**Daily IDE Chat Turns** + +- **Logic**: Average of chat turns per day per user from recent week. +- **Target/Max**: Reflects healthy usage from productive orgs. + +**Daily Dot-Com Chats** + +- **Logic**: Chat Turns \* 33% (estimated portion on dot-com). +- **Target**: Not yet set pending more data. + +**Weekly PR Summaries** + +- **Logic**: Total PR summaries / daily active users from last week. + +**Weekly Time Saved** + +- **Logic**: Weekly average from time savings reports per developer.\ + // Calculate weekly hours saved based on settings and average percent + + const weeklyHours = hoursPerYear / 50; // Assuming 50 working weeks + + const weeklyDevHours = weeklyHours \* (percentCoding / 100); + + const avgWeeklyTimeSaved = weeklyDevHours \* (avgPercentTimeSaved / 100); + +--- + +### Calculated Impacts + +**Monthly Time Savings (hrs)** + +- **Formula**: Adopted Devs \* Weekly Time Saved \* 4. +- **Max**: 80 hours/month \* total seats (full work month). + +**Annual Time Savings (Dollars)** + +- **Formula**: Weekly Time Saved \* 50 weeks \* \$100/hr \* Adopted Devs. +- **Note**: \$100/hr is assumed average developer cost. + +**Productivity / Throughput Boost** + +- **Formula**: ((40 + Weekly Time Saved) / 40 - 1) \* 100. +- **Purpose**: Estimates effective increase in output per dev. + +--- + +### Source of Calculations + +All calculations were derived from one or more of: + +- Recent metric exports (5 most recent days) +- Monthly time-savings surveys +- Developer seat and activity data +- Assumed baselines (e.g., 40-hr weeks, \$100/hr, 70% acceptance) + +Targets are either: + +- Reflective of past top 10 org benchmarks +- Strategically aspirational (2x current, known limits) + +--- + +This model provides a structured framework for tracking usage, estimating impact, and guiding adoption investments. + +> Edits can include notes on thresholds, cohort segmentation, or more nuanced modeling (e.g., p50/p90 range breakdowns). + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 7f974817..a600cf63 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,6 +15,7 @@ import { CopilotSeatComponent } from './main/copilot/copilot-seats/copilot-seat/ import { DatabaseComponent } from './database/database.component'; import { ErrorComponent } from './error/error.component'; import { CopilotValueModelingComponent } from './main/copilot/copilot-value-modeling/copilot-value-modeling.component'; +import { MainDiagnosticsComponent } from './main/diagnostics/main-diagnostics.component'; export const routes: Routes = [ { path: 'setup', component: InstallComponent }, @@ -38,6 +39,7 @@ export const routes: Routes = [ { path: 'copilot/surveys/:id', component: CopilotSurveyComponent, title: 'Survey' }, { path: 'copilot/value-modeling', component: CopilotValueModelingComponent, title: 'Value Modeling' }, { path: 'settings', component: SettingsComponent, title: 'Settings' }, + { path: 'diagnostics', component: MainDiagnosticsComponent, title: 'Diagnostics' }, { path: '', redirectTo: 'copilot', pathMatch: 'full' } ] }, diff --git a/frontend/src/app/main/diagnostics/main-diagnostics.component.ts b/frontend/src/app/main/diagnostics/main-diagnostics.component.ts new file mode 100644 index 00000000..dc1804cf --- /dev/null +++ b/frontend/src/app/main/diagnostics/main-diagnostics.component.ts @@ -0,0 +1,481 @@ +import { Component, Inject } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDialogModule, MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { SetupService } from '../../services/api/setup.service'; + +@Component({ + selector: 'app-main-diagnostics', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + MatToolbarModule + ], + template: ` + + bug_report + Installation Diagnostics + + +
+ + + GitHub App Installation Validator + Validate your GitHub App installations and Octokit connections + + +

This tool helps you diagnose issues with your GitHub App installations by:

+
    +
  • Validating that all installation accounts have proper data
  • +
  • Testing Octokit authentication for each installation
  • +
  • Listing all organization names (account.login) available
  • +
  • Verifying account types and permissions
  • +
  • Providing detailed error information for troubleshooting
  • +
+
+ + + + +
+ + + + Quick Summary + Last run: {{ lastResult.timestamp | date:'medium' }} + + +
+
+
{{ lastResult.totalInstallations }}
+
Total Installations
+
+
+
+ {{ lastResult.summary.validInstallations }} +
+
Valid
+
+
+
+ {{ lastResult.summary.invalidInstallations }} +
+
Invalid
+
+
+
{{ getSuccessRate() }}%
+
Success Rate
+
+
+ +
+

Organizations Found:

+
+ + {{ org }} + +
+
+
+ + + + +
+
+ `, + styles: [` + .diagnostics-container { + padding: 24px; + max-width: 1200px; + margin: 0 auto; + } + + .welcome-card, .results-card { + margin-bottom: 24px; + } + + .welcome-card ul { + margin: 16px 0; + padding-left: 24px; + } + + .welcome-card li { + margin: 8px 0; + } + + .summary-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; + margin: 16px 0; + } + + .stat { + text-align: center; + padding: 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; + } + + .stat-value { + font-size: 2em; + font-weight: bold; + color: #666; + } + + .stat-value.success { + color: #4caf50; + } + + .stat-value.error { + color: #f44336; + } + + .stat-label { + font-size: 0.875em; + color: #888; + margin-top: 4px; + } + + .organizations { + margin-top: 16px; + } + + .org-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + } + + .org-chip { + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 16px; + font-size: 0.875em; + border: 1px solid #bbdefb; + } + + mat-card-actions { + padding: 16px; + } + + mat-card-actions button { + margin-right: 8px; + } + `] +}) +export class MainDiagnosticsComponent { + isLoading = false; + lastResult: any = null; + + constructor( + private setupService: SetupService, + private dialog: MatDialog, + private snackBar: MatSnackBar + ) {} + + runDiagnostics(): void { + this.isLoading = true; + this.setupService.validateInstallations().subscribe({ + next: (result) => { + this.isLoading = false; + this.lastResult = result; + + if (result.summary.invalidInstallations > 0 || result.errors.length > 0) { + this.snackBar.open('Diagnostics completed with some issues. Check the details.', 'Close', { + duration: 5000 + }); + } else { + this.snackBar.open('All installations validated successfully!', 'Close', { + duration: 3000 + }); + } + }, + error: (error) => { + this.isLoading = false; + this.snackBar.open('Failed to run diagnostics: ' + error.message, 'Close', { + duration: 5000 + }); + } + }); + } + + getSuccessRate(): number { + if (!this.lastResult || this.lastResult.totalInstallations === 0) return 0; + return Math.round((this.lastResult.summary.validInstallations / this.lastResult.totalInstallations) * 100); + } + + showFullDetails(): void { + this.dialog.open(InstallationDiagnosticsDialogComponent, { + width: '90vw', + maxWidth: '1200px', + height: '80vh', + data: this.lastResult + }); + } + + downloadResults(): void { + if (!this.lastResult) return; + + const dataStr = JSON.stringify(this.lastResult, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `installation-diagnostics-${new Date().toISOString().split('T')[0]}.json`; + link.click(); + URL.revokeObjectURL(url); + } +} + +@Component({ + selector: 'app-installation-diagnostics-dialog', + template: ` +

Installation Diagnostics Details

+ +
+

Summary

+
+ + + App Status + + +

Connected: {{ data.appConnected ? 'Yes' : 'No' }}

+

Total Installations: {{ data.totalInstallations }}

+

Timestamp: {{ data.timestamp | date:'medium' }}

+
+
+ + + + Validation Results + + +

Valid: {{ data.summary.validInstallations }}

+

Invalid: {{ data.summary.invalidInstallations }}

+

Success Rate: {{ getSuccessRate() }}%

+
+
+
+ + + + App Information + + +

Name: {{ data.appInfo.name }}

+

Description: {{ data.appInfo.description }}

+

Owner: {{ data.appInfo.owner }}

+

HTML URL: {{ data.appInfo.htmlUrl }}

+
+
+ + + + Organizations ({{ data.summary.organizationNames.length }}) + + + + {{ org }} + + + +
+ +
+

Errors

+ + +
    +
  • {{ error }}
  • +
+
+
+
+ +
+

Installation Details

+ + + + + + {{ installation.isValid ? 'check_circle' : 'error' }} + + {{ installation.accountLogin }} (ID: {{ installation.installationId }}) + + + {{ installation.accountType }} - {{ installation.isValid ? 'Valid' : 'Invalid' }} + + + +
+
+
+ Installation ID: {{ installation.installationId }} +
+
+ Account Login: {{ installation.accountLogin }} +
+
+ Account ID: {{ installation.accountId }} +
+
+ Account Type: {{ installation.accountType }} +
+
+ App ID: {{ installation.appId }} +
+
+ Target Type: {{ installation.targetType }} +
+
+ Has Octokit: {{ installation.hasOctokit ? 'Yes' : 'No' }} +
+
+ Created: {{ installation.createdAt | date:'medium' }} +
+
+ +
+

Octokit Test

+

Success: {{ installation.octokitTest.success ? 'Yes' : 'No' }}

+
+

App Name: {{ installation.octokitTest.appName }}

+

App Owner: {{ installation.octokitTest.appOwner }}

+
+
+

Error: {{ installation.octokitTest.error }}

+
+
+ +
+

Validation Errors

+
    +
  • {{ error }}
  • +
+
+
+
+
+
+
+ + + + + `, + styles: [` + .diagnostics-content { + max-height: 70vh; + overflow-y: auto; + } + .summary-section { + margin-bottom: 20px; + } + .summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + margin-bottom: 16px; + } + .organizations-card { + margin-bottom: 16px; + } + .detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 8px; + margin-bottom: 16px; + } + .detail-item { + padding: 4px 0; + } + .error-item { + color: #f44336; + margin: 4px 0; + } + .invalid { + border-left: 4px solid #f44336; + } + .octokit-test, .validation-errors { + margin-top: 16px; + padding: 12px; + border: 1px solid #e0e0e0; + border-radius: 4px; + } + .error { + color: #f44336; + } + .installations-section { + margin-top: 20px; + } + .errors-section { + margin-bottom: 20px; + } + mat-chip-set { + margin: 8px 0; + } + `], + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatExpansionModule, + MatChipsModule, + CommonModule, + DatePipe + ] +}) +export class InstallationDiagnosticsDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any + ) {} + + getSuccessRate(): number { + if (this.data.totalInstallations === 0) return 0; + return Math.round((this.data.summary.validInstallations / this.data.totalInstallations) * 100); + } + + downloadDiagnostics(): void { + const dataStr = JSON.stringify(this.data, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `installation-diagnostics-${new Date().toISOString().split('T')[0]}.json`; + link.click(); + URL.revokeObjectURL(url); + } +} diff --git a/frontend/src/app/main/main.component.html b/frontend/src/app/main/main.component.html index e57f33d8..9e735623 100644 --- a/frontend/src/app/main/main.component.html +++ b/frontend/src/app/main/main.component.html @@ -43,6 +43,10 @@ settings Settings + + bug_report + Diagnostics + diff --git a/frontend/src/app/services/api/setup.service.ts b/frontend/src/app/services/api/setup.service.ts index 2a8dfc05..1b886516 100644 --- a/frontend/src/app/services/api/setup.service.ts +++ b/frontend/src/app/services/api/setup.service.ts @@ -60,4 +60,8 @@ export class SetupService { return this.http.post(`${this.apiUrl}/db`, request); } + validateInstallations() { + return this.http.get(`${this.apiUrl}/validate-installations`); + } + } From d25d64a5bf8b3681747d012896eb76991f15ffc6 Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 13:53:01 -0400 Subject: [PATCH 2/8] replaced any : types everywhere --- backend/src/controllers/setup.controller.ts | 82 ++++++++++++++++--- .../diagnostics/main-diagnostics.component.ts | 5 +- .../src/app/services/api/setup.service.ts | 3 +- frontend/src/app/types/diagnostics.types.ts | 54 ++++++++++++ 4 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 frontend/src/app/types/diagnostics.types.ts diff --git a/backend/src/controllers/setup.controller.ts b/backend/src/controllers/setup.controller.ts index 1280b8ba..89033812 100644 --- a/backend/src/controllers/setup.controller.ts +++ b/backend/src/controllers/setup.controller.ts @@ -2,6 +2,62 @@ import { Request, Response } from 'express'; import app from '../index.js'; import StatusService from '../services/status.service.js'; import logger from '../services/logger.js'; +import { Endpoints } from '@octokit/types'; + +// Type definitions for the diagnostic response +interface OctokitTestResult { + success: boolean; + appName?: string; + appOwner?: string; + permissions?: Record; + error?: string; +} + +interface InstallationDiagnostic { + index: number; + installationId: number; + accountLogin: string; + accountId: string | number; + accountType: string; + accountAvatarUrl: string; + appId: number; + appSlug: string; + targetType: string; + permissions: Record; + events: string[]; + createdAt: string; + updatedAt: string; + suspendedAt: string | null; + suspendedBy: { login: string; id: number } | null; + hasOctokit: boolean; + octokitTest: OctokitTestResult | null; + isValid: boolean; + validationErrors: string[]; +} + +interface AppInfo { + name: string; + description: string; + owner: string; + htmlUrl: string; + permissions: Record; + events: string[]; +} + +interface DiagnosticsResponse { + timestamp: string; + appConnected: boolean; + totalInstallations: number; + installations: InstallationDiagnostic[]; + errors: string[]; + appInfo: AppInfo | null; + summary: { + validInstallations: number; + invalidInstallations: number; + organizationNames: string[]; + accountTypes: Record; + }; +} class SetupController { async registrationComplete(req: Request, res: Response) { @@ -114,18 +170,18 @@ class SetupController { async validateInstallations(req: Request, res: Response) { try { - const diagnostics = { + const diagnostics: DiagnosticsResponse = { timestamp: new Date().toISOString(), appConnected: !!app.github.app, totalInstallations: app.github.installations.length, - installations: [] as any[], - errors: [] as string[], - appInfo: null as any, + installations: [], + errors: [], + appInfo: null, summary: { validInstallations: 0, invalidInstallations: 0, - organizationNames: [] as string[], - accountTypes: {} as Record + organizationNames: [], + accountTypes: {} } }; @@ -139,7 +195,7 @@ class SetupController { for (let i = 0; i < app.github.installations.length; i++) { const { installation, octokit } = app.github.installations[i]; - const installationDiag = { + const installationDiag: InstallationDiagnostic = { index: i, installationId: installation.id, accountLogin: installation.account?.login || 'MISSING', @@ -149,16 +205,16 @@ class SetupController { appId: installation.app_id, appSlug: installation.app_slug, targetType: installation.target_type, - permissions: installation.permissions, - events: installation.events, + permissions: installation.permissions || {}, + events: installation.events || [], createdAt: installation.created_at, updatedAt: installation.updated_at, suspendedAt: installation.suspended_at, suspendedBy: installation.suspended_by, hasOctokit: !!octokit, - octokitTest: null as any, + octokitTest: null, isValid: true, - validationErrors: [] as string[] + validationErrors: [] }; // Validate required fields @@ -185,7 +241,7 @@ class SetupController { installationDiag.octokitTest = { success: true, appName: authTest.data?.name || 'Unknown', - appOwner: (authTest.data?.owner as any)?.login || 'Unknown', + appOwner: (authTest.data?.owner && 'login' in authTest.data.owner) ? authTest.data.owner.login : 'Unknown', permissions: authTest.data?.permissions || {} }; } catch (error) { @@ -224,7 +280,7 @@ class SetupController { diagnostics.appInfo = { name: appInfo.data?.name || 'Unknown', description: appInfo.data?.description || 'No description', - owner: (appInfo.data?.owner as any)?.login || 'Unknown', + owner: (appInfo.data?.owner && 'login' in appInfo.data.owner) ? appInfo.data.owner.login : 'Unknown', htmlUrl: appInfo.data?.html_url || 'Unknown', permissions: appInfo.data?.permissions || {}, events: appInfo.data?.events || [] diff --git a/frontend/src/app/main/diagnostics/main-diagnostics.component.ts b/frontend/src/app/main/diagnostics/main-diagnostics.component.ts index dc1804cf..2fa30939 100644 --- a/frontend/src/app/main/diagnostics/main-diagnostics.component.ts +++ b/frontend/src/app/main/diagnostics/main-diagnostics.component.ts @@ -10,6 +10,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatToolbarModule } from '@angular/material/toolbar'; import { SetupService } from '../../services/api/setup.service'; +import { DiagnosticsResponse } from '../../types/diagnostics.types'; @Component({ selector: 'app-main-diagnostics', @@ -192,7 +193,7 @@ import { SetupService } from '../../services/api/setup.service'; }) export class MainDiagnosticsComponent { isLoading = false; - lastResult: any = null; + lastResult: DiagnosticsResponse | null = null; constructor( private setupService: SetupService, @@ -460,7 +461,7 @@ export class MainDiagnosticsComponent { export class InstallationDiagnosticsDialogComponent { constructor( public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: any + @Inject(MAT_DIALOG_DATA) public data: DiagnosticsResponse ) {} getSuccessRate(): number { diff --git a/frontend/src/app/services/api/setup.service.ts b/frontend/src/app/services/api/setup.service.ts index 1b886516..472858d5 100644 --- a/frontend/src/app/services/api/setup.service.ts +++ b/frontend/src/app/services/api/setup.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { serverUrl } from '../server.service'; import { Endpoints } from '@octokit/types'; import { BehaviorSubject } from 'rxjs'; +import { DiagnosticsResponse } from '../../types/diagnostics.types'; export interface InstallationStatus { installation?: Endpoints["GET /app/installations"]["response"]["data"][number], @@ -61,7 +62,7 @@ export class SetupService { } validateInstallations() { - return this.http.get(`${this.apiUrl}/validate-installations`); + return this.http.get(`${this.apiUrl}/validate-installations`); } } diff --git a/frontend/src/app/types/diagnostics.types.ts b/frontend/src/app/types/diagnostics.types.ts new file mode 100644 index 00000000..63b95b2e --- /dev/null +++ b/frontend/src/app/types/diagnostics.types.ts @@ -0,0 +1,54 @@ +// Type definitions for the diagnostic response from backend +export interface OctokitTestResult { + success: boolean; + appName?: string; + appOwner?: string; + permissions?: Record; + error?: string; +} + +export interface InstallationDiagnostic { + index: number; + installationId: number; + accountLogin: string; + accountId: string | number; + accountType: string; + accountAvatarUrl: string; + appId: number; + appSlug: string; + targetType: string; + permissions: Record; + events: string[]; + createdAt: string; + updatedAt: string; + suspendedAt: string | null; + suspendedBy: { login: string; id: number } | null; + hasOctokit: boolean; + octokitTest: OctokitTestResult | null; + isValid: boolean; + validationErrors: string[]; +} + +export interface AppInfo { + name: string; + description: string; + owner: string; + htmlUrl: string; + permissions: Record; + events: string[]; +} + +export interface DiagnosticsResponse { + timestamp: string; + appConnected: boolean; + totalInstallations: number; + installations: InstallationDiagnostic[]; + errors: string[]; + appInfo: AppInfo | null; + summary: { + validInstallations: number; + invalidInstallations: number; + organizationNames: string[]; + accountTypes: Record; + }; +} From 9f21f23d7dcb43bd193b4cdaacdbcf1a03752e90 Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 13:55:31 -0400 Subject: [PATCH 3/8] removed unused type --- backend/src/controllers/setup.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/controllers/setup.controller.ts b/backend/src/controllers/setup.controller.ts index 89033812..1b2bec97 100644 --- a/backend/src/controllers/setup.controller.ts +++ b/backend/src/controllers/setup.controller.ts @@ -2,7 +2,6 @@ import { Request, Response } from 'express'; import app from '../index.js'; import StatusService from '../services/status.service.js'; import logger from '../services/logger.js'; -import { Endpoints } from '@octokit/types'; // Type definitions for the diagnostic response interface OctokitTestResult { From 602d9ff8c701e9636f5c98c372453ea177c7b4f1 Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 15:46:38 -0400 Subject: [PATCH 4/8] Merge value modeling improvements from targeting-bugfixes while preserving diagnostics - Added recalculate targets functionality with save all button - Enhanced time-saved chart with dynamic Y-axis and target lines - Fixed type issues in target-calculation-service.ts using parseFloat for string conversions - Simplified status.service.ts repositories API call - Added value-modeling-explanation.md documentation - Maintained all diagnostic functionality and type safety --- backend/src/services/status.service.ts | 8 +- .../services/target-calculation-service.ts | 21 ++--- .../services/value-modeling-explanation.md | 89 +++++++++++++++++++ 3 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 backend/src/services/value-modeling-explanation.md diff --git a/backend/src/services/status.service.ts b/backend/src/services/status.service.ts index ea3db4af..20498fea 100644 --- a/backend/src/services/status.service.ts +++ b/backend/src/services/status.service.ts @@ -11,7 +11,7 @@ export interface StatusType { }; installations: { installation: Endpoints["GET /app/installations"]["response"]["data"][0] - repos: Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"]; + repos: Endpoints["GET /app/installations"]["response"]["data"]; }[]; surveyCount: number; auth?: { @@ -56,12 +56,10 @@ class StatusService { status.installations = []; for (const installation of app.github.installations) { - const repos = await installation.octokit.paginate( - installation.installation.repositories_url - ); + const repos = await installation.octokit.request(installation.installation.repositories_url); status.installations.push({ installation: installation.installation, - repos: repos + repos: repos.data.repositories }); } diff --git a/backend/src/services/target-calculation-service.ts b/backend/src/services/target-calculation-service.ts index d42c722b..32636725 100644 --- a/backend/src/services/target-calculation-service.ts +++ b/backend/src/services/target-calculation-service.ts @@ -702,7 +702,8 @@ RESULT: const userTimeSavings = distinctUsers.map(userId => { const userSurveys = this.surveysWeekly.filter(survey => survey.userId === userId); const totalPercent = userSurveys.reduce((sum, survey) => { - const percentTimeSaved = typeof survey.percentTimeSaved === 'number' ? survey.percentTimeSaved : 0; + // Always parse percentTimeSaved as float + const percentTimeSaved = survey.percentTimeSaved != null ? parseFloat(survey.percentTimeSaved as any) : 0; return sum + percentTimeSaved; }, 0); return totalPercent / userSurveys.length; // Average percent time saved per user @@ -711,9 +712,9 @@ RESULT: // Average across all users const avgPercentTimeSaved = userTimeSavings.reduce((sum, percent) => sum + percent, 0) / userTimeSavings.length; - // Convert settings values to numbers - const hoursPerYear = typeof this.settings.hoursPerYear === 'number' ? this.settings.hoursPerYear : 2000; - const percentCoding = typeof this.settings.percentCoding === 'number' ? this.settings.percentCoding : 50; + // Convert settings values to numbers (parse from string if needed) + const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; + const percentCoding = this.settings.percentCoding != null ? parseFloat(this.settings.percentCoding as any) : 50; // Calculate weekly hours saved based on settings and average percent const weeklyHours = hoursPerYear / 50; // Assuming 50 working weeks @@ -721,7 +722,7 @@ RESULT: const avgWeeklyTimeSaved = weeklyDevHours * (avgPercentTimeSaved / 100); // Calculate max based on settings - const maxPercentTimeSaved = typeof this.settings.percentTimeSaved === 'number' ? this.settings.percentTimeSaved : 20; + const maxPercentTimeSaved = this.settings.percentTimeSaved != null ? parseFloat(this.settings.percentTimeSaved as any) : 20; const maxWeeklyTimeSaved = weeklyDevHours * (maxPercentTimeSaved / 100); const result = { @@ -785,11 +786,11 @@ RESULT: const adoptedDevs = this.calculateAdoptedDevs().current; const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; - // Ensure all values are properly typed as numbers - const hoursPerYear = typeof this.settings.hoursPerYear === 'number' ? this.settings.hoursPerYear : 2000; + // Always parse settings values as numbers (from string if needed) + const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; const weeksInYear = Math.round(hoursPerYear / 40) || 50; // Calculate weeks and ensure it's a number - const devCostPerYear = typeof this.settings.devCostPerYear === 'number' ? this.settings.devCostPerYear : 0; + const devCostPerYear = this.settings.devCostPerYear != null ? parseFloat(this.settings.devCostPerYear as any) : 0; const hourlyRate = devCostPerYear > 0 ? (devCostPerYear / hoursPerYear) : 50; const annualSavings = weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs; @@ -824,8 +825,8 @@ RESULT: const adoptedDevs = this.calculateAdoptedDevs().current; const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; - // Convert hours per year to number - const hoursPerYear = typeof this.settings.hoursPerYear === 'number' ? this.settings.hoursPerYear : 2000; + // Always parse hours per year as number + const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; const hoursPerWeek = hoursPerYear / 50 || 40; // Default to 40 if undefined // Calculate productivity boost factor (not percentage) diff --git a/backend/src/services/value-modeling-explanation.md b/backend/src/services/value-modeling-explanation.md new file mode 100644 index 00000000..da440919 --- /dev/null +++ b/backend/src/services/value-modeling-explanation.md @@ -0,0 +1,89 @@ +# Value Modeling & Targeting Documentation + +This document outlines the rationale and logic behind the calculated metrics and targets used in the "Value Modeling & Targeting" dashboard. Each section corresponds to a category of metrics displayed in the dashboard. + +## Org Metrics + +### Seats +- **Logic**: Based on the average total active seats (licenses) across top 10 recent days for the organization. +- **Max**: Set to known total developer headcount. + +### Adopted Devs +- **Logic**: Average of total active developers using AI tooling (e.g. Copilot) from top 10 recent days for the organization. +- **Max**: Total known developer count. + +### Monthly Devs Reporting Time Savings +- **Logic**: Count of distinct users who responded to time-savings surveys in past 30 days. +- **Target**: Double the current, indicating intent to increase reporting. + +### % of Seats Reporting Time Savings +- **Logic**: (Monthly reporting users / total seats) * 100. +- **Purpose**: Shows how broadly time savings are captured. + +### % of Seats Adopted +- **Logic**: (Adopted Devs / Total Seats) * 100. +- **Use**: Adoption penetration relative to seat assignments. + +### % of Max Adopted +- **Logic**: (Adopted Devs / Total Developer Count) * 100. +- **Use**: Indicates potential ceiling for adoption. + +## Daily User Metrics + +### Daily IDE Suggestions +- **Logic**: Averaged from last 5 valid daily records. +- **Target/Max**: Calibrated based on observed high-performing usage. + +### Daily IDE Acceptances +- **Logic**: Suggestions * 30% (default assumed acceptance rate). +- **Target/Max**: Reflects healthy usage from productive orgs. + +### Daily IDE Chat Turns +- **Logic**: Average of chat turns per day per user from recent week. +- **Target/Max**: Reflects healthy usage from productive orgs. + +### Daily Dot-Com Chats +- **Logic**: Chat Turns * 33% (estimated portion on dot-com). +- **Target**: Not yet set pending more data. + +### Weekly PR Summaries +- **Logic**: Total PR summaries / daily active users from last week. + +### Weekly Time Saved +- **Logic**: Weekly average from time savings reports per developer. +- **Calculation**: + ``` + const weeklyHours = hoursPerYear / 50; // Assuming 50 working weeks + const weeklyDevHours = weeklyHours * (percentCoding / 100); + const avgWeeklyTimeSaved = weeklyDevHours * (avgPercentTimeSaved / 100); + ``` + +## Calculated Impacts + +### Monthly Time Savings (hrs) +- **Formula**: Adopted Devs * Weekly Time Saved * 4. +- **Max**: 80 hours/month * total seats (full work month). + +### Annual Time Savings (Dollars) +- **Formula**: Weekly Time Saved * 50 weeks * $100/hr * Adopted Devs. +- **Note**: $100/hr is assumed average developer cost. + +### Productivity / Throughput Boost +- **Formula**: ((40 + Weekly Time Saved) / 40 - 1) * 100. +- **Purpose**: Estimates effective increase in output per dev. + +## Source of Calculations + +All calculations were derived from one or more of: +- Recent metric exports (5 most recent days) +- Monthly time-savings surveys +- Developer seat and activity data +- Assumed baselines (e.g., 40-hr weeks, $100/hr, 70% acceptance) + +Targets are either: +- Reflective of past top 10 org benchmarks +- Strategically aspirational (2x current, known limits) + +This model provides a structured framework for tracking usage, estimating impact, and guiding adoption investments. + +> Edits can include notes on thresholds, cohort segmentation, or more nuanced modeling (e.g., p50/p90 range breakdowns). From 0a73427112f7d4779ac6724bdb9ff9be06bd61b0 Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 16:31:43 -0400 Subject: [PATCH 5/8] Updated targeting to fix bugs, remove any : types and default impact calculations. --- .../services/target-calculation-service.ts | 37 ++++++++++------- .../copilot-value-modeling.component.html | 8 ++++ .../copilot-value-modeling.component.ts | 16 +++++++- .../time-saved-chart.component.ts | 40 ++++++++++++++++++- .../src/app/services/api/targets.service.ts | 5 +++ 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/backend/src/services/target-calculation-service.ts b/backend/src/services/target-calculation-service.ts index 32636725..d4e3ed31 100644 --- a/backend/src/services/target-calculation-service.ts +++ b/backend/src/services/target-calculation-service.ts @@ -687,15 +687,15 @@ RESULT: * Calculate weekly time saved in hours per developer */ calculateWeeklyTimeSavedHrs(): Target { - // If no surveys, return default values + // If no surveys, return default values with 2 hrs current if (this.surveysWeekly.length === 0) { - return { current: 0, target: 0, max: 10 }; + return { current: 2, target: 2, max: 10 }; } // Get distinct users who submitted surveys const distinctUsers = this.getDistinctSurveyUsers(this.surveysWeekly); if (distinctUsers.length === 0) { - return { current: 0, target: 0, max: 10 }; + return { current: 2, target: 2, max: 10 }; } // Group surveys by user to get average time saved per user @@ -725,9 +725,13 @@ RESULT: const maxPercentTimeSaved = this.settings.percentTimeSaved != null ? parseFloat(this.settings.percentTimeSaved as any) : 20; const maxWeeklyTimeSaved = weeklyDevHours * (maxPercentTimeSaved / 100); + // Use default value of 2 if calculated value is 0 or very small + const currentValue = avgWeeklyTimeSaved < 0.1 ? 2 : this.roundToDecimal(avgWeeklyTimeSaved); + const targetValue = avgWeeklyTimeSaved < 0.1 ? 3 : this.roundToDecimal(Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8)); + const result = { - current: this.roundToDecimal(avgWeeklyTimeSaved), - target: this.roundToDecimal(Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8)), // Target is 50% increase, capped at 80% of max + current: currentValue, + target: targetValue, // Target is 50% increase, capped at 80% of max max: this.roundToDecimal(maxWeeklyTimeSaved || 10) // Provide a fallback }; @@ -740,9 +744,11 @@ RESULT: userPercentages: userTimeSavings, hoursPerYear: hoursPerYear, percentCoding: percentCoding, - weeklyDevHours: weeklyDevHours + weeklyDevHours: weeklyDevHours, + calculatedWeeklyTimeSaved: avgWeeklyTimeSaved, + usedDefaultValue: avgWeeklyTimeSaved < 0.1 }, - 'Calculate average time saved percentage per user, then weeklyDevHours * (avgPercentTimeSaved / 100)', + 'Calculate average time saved percentage per user, then weeklyDevHours * (avgPercentTimeSaved / 100), use default value of 2 if result is < 0.1', result ); @@ -770,9 +776,11 @@ RESULT: { adoptedDevsCount: adoptedDevs, weeklyTimeSavedHrs: weeklyTimeSavedHrs, + monthlyCalculation: `${adoptedDevs} * ${weeklyTimeSavedHrs} * 4 = ${monthlyTimeSavings}`, + calculatedMonthlyTimeSavings: monthlyTimeSavings, seatsCount: this.calculateSeats().current }, - 'Calculate adoptedDevs * weeklyTimeSavedHrs * 4, set current = monthlyTimeSavings, max = 80 * seats', + 'Calculate adoptedDevs * weeklyTimeSavedHrs * 4 (weeklyTimeSavedHrs already includes default of 2 if needed), max = 80 * seats', result ); @@ -784,7 +792,7 @@ RESULT: */ calculateAnnualTimeSavingsAsDollars(): Target { const adoptedDevs = this.calculateAdoptedDevs().current; - const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; + const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; // This now includes default of 2 if needed // Always parse settings values as numbers (from string if needed) const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; @@ -799,7 +807,7 @@ RESULT: const result = { current: Math.round(annualSavings || 0), // Round to whole dollars target: 0, - max: Math.round(12 * this.calculateSeats().current * weeksInYear * hourlyRate || 10000) // Provide fallback + max: Math.round(weeksInYear * this.calculateSeats().current * hourlyRate * 40 || 10000) // Max assumes 40 hours per week saved per seat }; this.logCalculation( @@ -809,9 +817,10 @@ RESULT: weeklyTimeSavedHrs: weeklyTimeSavedHrs, weeksInYear: weeksInYear, hourlyRate: hourlyRate, + annualSavingsCalculation: `${weeklyTimeSavedHrs} * ${weeksInYear} * ${hourlyRate} * ${adoptedDevs} = ${annualSavings}`, seatsCount: this.calculateSeats().current }, - 'Calculate weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs, set current = annualSavings, max = 80 * seats * 50', + 'Calculate weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs (weeklyTimeSavedHrs includes default of 2 if needed)', result ); @@ -823,7 +832,7 @@ RESULT: */ calculateProductivityOrThroughputBoostPercent(): Target { const adoptedDevs = this.calculateAdoptedDevs().current; - const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; + const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; // This now includes default of 2 if needed // Always parse hours per year as number const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; @@ -844,13 +853,13 @@ RESULT: this.logCalculation( 'PRODUCTIVITY OR THROUGHPUT BOOST PERCENT', { - adoptedDevsCount: adoptedDevs, + adoptedDevsCount: adoptedDevs, weeklyTimeSavedHrs: weeklyTimeSavedHrs, hoursPerWeek: hoursPerWeek, productivityBoostFactor: productivityBoost, productivityBoostPercent: productivityBoostPercent }, - 'Calculate boost factor as (hoursPerWeek + weeklyTimeSavedHrs) / hoursPerWeek, then convert to percentage by (factor - 1) * 100', + 'Calculate boost factor as (hoursPerWeek + weeklyTimeSavedHrs) / hoursPerWeek, then convert to percentage by (factor - 1) * 100 (weeklyTimeSavedHrs includes default of 2 if needed)', result ); diff --git a/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.html b/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.html index 1368ba70..93f145ce 100644 --- a/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.html +++ b/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.html @@ -1,6 +1,14 @@

Org Metrics

diff --git a/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts b/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts index b0a72a27..f3cb28a2 100644 --- a/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts +++ b/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts @@ -37,6 +37,7 @@ export class CopilotValueModelingComponent implements OnInit { orgDataSource: TableTarget[] = []; userDataSource: TableTarget[] = []; impactDataSource: TableTarget[] = []; + showSaveAllButton = false; private readonly _destroy$ = new Subject(); keyToNameMap: Record = { seats: 'Seats', @@ -128,7 +129,9 @@ export class CopilotValueModelingComponent implements OnInit { saveTargets() { const targets: Targets = this.transformBackToTargets(this.orgDataSource, this.userDataSource, this.impactDataSource); - this.targetsService.saveTargets(targets).subscribe(); + this.targetsService.saveTargets(targets).subscribe(() => { + this.showSaveAllButton = false; + }); } openEditDialog(target: Target) { @@ -144,6 +147,17 @@ export class CopilotValueModelingComponent implements OnInit { } }); } + + resetTargets() { + // Call the backend endpoint to recalculate targets + this.targetsService.recalculateTargets().subscribe((result: any) => { + const targets = result.targets || result; // handle both {targets, logs} and just targets + this.orgDataSource = this.transformTargets(targets.org); + this.userDataSource = this.transformTargets(targets.user); + this.impactDataSource = this.transformTargets(targets.impact); + this.showSaveAllButton = true; + }); + } } @Component({ diff --git a/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts b/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts index 8ceea2e2..9df7124e 100644 --- a/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts @@ -29,7 +29,7 @@ export class TimeSavedChartComponent implements OnInit, OnChanges { text: 'Time Saved (Hrs per Week)' }, min: 0, - max: 10, + max: 10, // Will be updated dynamically labels: { format: '{value}hrs' }, @@ -45,7 +45,7 @@ export class TimeSavedChartComponent implements OnInit, OnChanges { } }], plotLines: [{ - value: 5, + value: 5, // Will be updated dynamically color: 'var(--sys-primary)', dashStyle: 'Dash', width: 2, @@ -91,6 +91,7 @@ export class TimeSavedChartComponent implements OnInit, OnChanges { this._chartOptions.yAxis = Object.assign({}, this.chartOptions?.yAxis, this._chartOptions.yAxis); this._chartOptions.tooltip = Object.assign({}, this.chartOptions?.tooltip, this._chartOptions.tooltip); this._chartOptions = Object.assign({}, this.chartOptions, this._chartOptions); + this.updateYAxisFromTargets(); } ngOnChanges() { @@ -101,6 +102,41 @@ export class TimeSavedChartComponent implements OnInit, OnChanges { }; this.updateFlag = true; } + this.updateYAxisFromTargets(); + } + + private updateYAxisFromTargets() { + if (this.targets?.user?.weeklyTimeSavedHrs) { + const targetValue = this.targets.user.weeklyTimeSavedHrs.target; + const maxValue = Math.max( + targetValue * 1.5, + this.targets.user.weeklyTimeSavedHrs.max || 10, + 10 + ); + const yAxis = { + ...this._chartOptions.yAxis, + max: maxValue, + plotLines: [{ + value: targetValue, + color: 'var(--sys-primary)', + dashStyle: 'Dash' as Highcharts.DashStyleValue, + width: 2, + label: { + text: 'Target Level', + align: 'left' as Highcharts.AlignValue, + style: { + color: 'var(--sys-primary)' + } + }, + zIndex: 2 + }] + }; + this._chartOptions = { + ...this._chartOptions, + yAxis + }; + this.updateFlag = true; + } } } diff --git a/frontend/src/app/services/api/targets.service.ts b/frontend/src/app/services/api/targets.service.ts index 86f4ea15..59ba1bd0 100644 --- a/frontend/src/app/services/api/targets.service.ts +++ b/frontend/src/app/services/api/targets.service.ts @@ -77,5 +77,10 @@ export class TargetsService { saveTargets(targets: Targets) { return this.http.post(`${this.apiUrl}`, targets); } + + recalculateTargets() { + // Calls the backend endpoint to recalculate targets + return this.http.get(`${this.apiUrl}/calculate`); + } } From f48bb22f15170a5e185748d9e720b6fb44acc88a Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 16:40:39 -0400 Subject: [PATCH 6/8] Implement type-safe parsing for numeric values in TargetCalculationService --- .../services/target-calculation-service.ts | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/backend/src/services/target-calculation-service.ts b/backend/src/services/target-calculation-service.ts index d4e3ed31..9b247157 100644 --- a/backend/src/services/target-calculation-service.ts +++ b/backend/src/services/target-calculation-service.ts @@ -204,6 +204,22 @@ RESULT: // === UTILITY CALCULATION METHODS === + /** + * Type-safe parsing of numeric values that might be strings or numbers + * @param value The value to parse (string | number | null | undefined) + * @param defaultValue The default value to use if parsing fails + * @returns A valid number + */ + private parseNumericValue(value: string | number | null | undefined, defaultValue: number): number { + if (value == null) return defaultValue; + if (typeof value === 'number') return isNaN(value) ? defaultValue : value; + if (typeof value === 'string') { + const parsed = parseFloat(value); + return isNaN(parsed) ? defaultValue : parsed; + } + return defaultValue; + } + /** * Round a number to a specified number of decimal places * @param value The number to round @@ -702,8 +718,8 @@ RESULT: const userTimeSavings = distinctUsers.map(userId => { const userSurveys = this.surveysWeekly.filter(survey => survey.userId === userId); const totalPercent = userSurveys.reduce((sum, survey) => { - // Always parse percentTimeSaved as float - const percentTimeSaved = survey.percentTimeSaved != null ? parseFloat(survey.percentTimeSaved as any) : 0; + // Use type-safe parsing for percentTimeSaved + const percentTimeSaved = this.parseNumericValue(survey.percentTimeSaved, 0); return sum + percentTimeSaved; }, 0); return totalPercent / userSurveys.length; // Average percent time saved per user @@ -712,9 +728,9 @@ RESULT: // Average across all users const avgPercentTimeSaved = userTimeSavings.reduce((sum, percent) => sum + percent, 0) / userTimeSavings.length; - // Convert settings values to numbers (parse from string if needed) - const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; - const percentCoding = this.settings.percentCoding != null ? parseFloat(this.settings.percentCoding as any) : 50; + // Convert settings values to numbers using type-safe parsing + const hoursPerYear = this.parseNumericValue(this.settings.hoursPerYear, 2000); + const percentCoding = this.parseNumericValue(this.settings.percentCoding, 50); // Calculate weekly hours saved based on settings and average percent const weeklyHours = hoursPerYear / 50; // Assuming 50 working weeks @@ -722,7 +738,7 @@ RESULT: const avgWeeklyTimeSaved = weeklyDevHours * (avgPercentTimeSaved / 100); // Calculate max based on settings - const maxPercentTimeSaved = this.settings.percentTimeSaved != null ? parseFloat(this.settings.percentTimeSaved as any) : 20; + const maxPercentTimeSaved = this.parseNumericValue(this.settings.percentTimeSaved, 20); const maxWeeklyTimeSaved = weeklyDevHours * (maxPercentTimeSaved / 100); // Use default value of 2 if calculated value is 0 or very small @@ -794,11 +810,11 @@ RESULT: const adoptedDevs = this.calculateAdoptedDevs().current; const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; // This now includes default of 2 if needed - // Always parse settings values as numbers (from string if needed) - const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; + // Use type-safe parsing for settings values + const hoursPerYear = this.parseNumericValue(this.settings.hoursPerYear, 2000); const weeksInYear = Math.round(hoursPerYear / 40) || 50; // Calculate weeks and ensure it's a number - const devCostPerYear = this.settings.devCostPerYear != null ? parseFloat(this.settings.devCostPerYear as any) : 0; + const devCostPerYear = this.parseNumericValue(this.settings.devCostPerYear, 0); const hourlyRate = devCostPerYear > 0 ? (devCostPerYear / hoursPerYear) : 50; const annualSavings = weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs; @@ -834,8 +850,8 @@ RESULT: const adoptedDevs = this.calculateAdoptedDevs().current; const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; // This now includes default of 2 if needed - // Always parse hours per year as number - const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000; + // Use type-safe parsing for hours per year + const hoursPerYear = this.parseNumericValue(this.settings.hoursPerYear, 2000); const hoursPerWeek = hoursPerYear / 50 || 40; // Default to 40 if undefined // Calculate productivity boost factor (not percentage) From e8486e5f41bbf367f0008d17660661affb8abf4a Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 16:49:50 -0400 Subject: [PATCH 7/8] Enhance type safety by updating recalculateTargets response handling and defining RecalculateTargetsResponse type --- .../copilot-value-modeling.component.ts | 7 ++++--- frontend/src/app/services/api/targets.service.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts b/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts index f3cb28a2..69cf4043 100644 --- a/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts +++ b/frontend/src/app/main/copilot/copilot-value-modeling/copilot-value-modeling.component.ts @@ -2,7 +2,7 @@ import { Component, inject, OnInit } from '@angular/core'; import { MatTableModule } from '@angular/material/table'; import { Subject, takeUntil } from 'rxjs'; import { InstallationsService } from '../../../services/api/installations.service'; -import { Target, Targets, TargetsService } from '../../../services/api/targets.service'; +import { Target, Targets, TargetsService, RecalculateTargetsResponse } from '../../../services/api/targets.service'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialog, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog'; @@ -150,8 +150,9 @@ export class CopilotValueModelingComponent implements OnInit { resetTargets() { // Call the backend endpoint to recalculate targets - this.targetsService.recalculateTargets().subscribe((result: any) => { - const targets = result.targets || result; // handle both {targets, logs} and just targets + this.targetsService.recalculateTargets().subscribe((result: RecalculateTargetsResponse) => { + // Handle response format - could be {targets: Targets, logs?: any[]} or just Targets + const targets = 'targets' in result ? result.targets : result as Targets; this.orgDataSource = this.transformTargets(targets.org); this.userDataSource = this.transformTargets(targets.user); this.impactDataSource = this.transformTargets(targets.impact); diff --git a/frontend/src/app/services/api/targets.service.ts b/frontend/src/app/services/api/targets.service.ts index 59ba1bd0..eab4a43d 100644 --- a/frontend/src/app/services/api/targets.service.ts +++ b/frontend/src/app/services/api/targets.service.ts @@ -34,6 +34,14 @@ export interface Targets { } } +export interface TargetsCalculationResponse { + targets: Targets; + logs?: any[]; // Optional calculation logs +} + +// Union type to handle both response formats +export type RecalculateTargetsResponse = TargetsCalculationResponse | Targets; + @Injectable({ providedIn: 'root' }) @@ -80,7 +88,7 @@ export class TargetsService { recalculateTargets() { // Calls the backend endpoint to recalculate targets - return this.http.get(`${this.apiUrl}/calculate`); + return this.http.get(`${this.apiUrl}/calculate`); } } From 51b7898e3614c56bac84e1981463e16e2de364fb Mon Sep 17 00:00:00 2001 From: Matt Gunter Date: Thu, 21 Aug 2025 16:52:33 -0400 Subject: [PATCH 8/8] Refactor TargetsCalculationResponse to use CalculationLog type for logs --- frontend/src/app/services/api/targets.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/services/api/targets.service.ts b/frontend/src/app/services/api/targets.service.ts index eab4a43d..1e8a75ba 100644 --- a/frontend/src/app/services/api/targets.service.ts +++ b/frontend/src/app/services/api/targets.service.ts @@ -34,9 +34,16 @@ export interface Targets { } } +export interface CalculationLog { + name: string; + inputs: Record; + formula: string; + result: unknown; +} + export interface TargetsCalculationResponse { targets: Targets; - logs?: any[]; // Optional calculation logs + logs?: CalculationLog[]; // Optional calculation logs } // Union type to handle both response formats