diff --git a/.DS_Store b/.DS_Store index 75d41099..ea94067d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..35719e38 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "eslint.workingDirectories": [ "./backend", "./frontend" ], +} \ No newline at end of file diff --git a/backend/jest.config.ts b/backend/jest.config.ts deleted file mode 100644 index 03f4ec08..00000000 --- a/backend/jest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Config } from 'jest'; - -const config: Config = { - preset: 'ts-jest', - testEnvironment: 'node', - transform: { - '^.+\\.tsx?$': ['ts-jest', { - tsconfig: { - target: 'ES6', - module: 'nodenext', // Ensure this is set to 'nodenext' - moduleResolution: 'nodenext', - }, - }], - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, - testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], - roots: ['/src'], -}; - -export default config; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 089052c0..cba104f7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "cron": "^4.1.3", "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "eventsource": "^3.0.6", "express": "^4.21.2", @@ -2420,6 +2421,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index aea157ea..9f01e32b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "cors": "^2.8.5", "cron": "^4.1.3", "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "eventsource": "^3.0.6", "express": "^4.21.2", diff --git a/backend/src/app.ts b/backend/src/app.ts index 81f22ad3..19f0656d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -32,11 +32,15 @@ class App { // throw new Error('MONGODB_URI must be set'); // } this.database = new Database(); + + // Create webhook service with just port/path, but without URL + // The URL will be populated from settings later const webhookService = new WebhookService({ - url: process.env.WEBHOOK_PROXY_URL, + url: process.env.WEBHOOK_PROXY_URL || undefined, // Use env value or undefined path: '/api/github/webhooks', port }); + this.github = new GitHub( { // adding GH_APP_* so you can set these as codespaces secrets, can't use GITHUB_* as a prefix for those @@ -88,7 +92,7 @@ class App { await TargetValuesService.initialize(); logger.info('Targets initialized'); } catch (error) { - logger.warn('GitHub App failed to connect', (error as any)?.message || error); + logger.warn('GitHub App failed to connect', error instanceof Error ? error.message : String(error)); } } @@ -135,7 +139,7 @@ class App { logger.info(`eListener on port ${this.port} (http://localhost:${this.port})`); } - private initializeSettings() { + private async initializeSettings() { return this.settingsService.initialize() .then(async (settings) => { if (settings.webhookSecret) { @@ -151,11 +155,27 @@ class App { if (settings.baseUrl) { this.baseUrl = settings.baseUrl; } + + // Add this section to properly set the webhook URL from settings + if (settings.webhookProxyUrl) { + // Update the webhook service with the stored URL + await this.github.webhookService.connect({ url: settings.webhookProxyUrl }); + logger.info(`Using stored webhook URL: ${settings.webhookProxyUrl}`); + } }) .finally(async () => { await this.settingsService.updateSetting('webhookSecret', this.github.input.webhooks?.secret || '', false); await this.settingsService.updateSetting('webhookProxyUrl', this.github.webhookService.url!, false); await this.settingsService.updateSetting('metricsCronExpression', this.github.cronExpression!, false); + + // Make sure we store the current URL after connection + const currentUrl = this.github.webhookService.url; + if (currentUrl) { + await this.settingsService.updateSetting('webhookProxyUrl', currentUrl, false); + logger.info(`Saved webhook URL: ${currentUrl}`); + } else { + logger.warn('No webhook URL available to save'); + } }) } } diff --git a/backend/src/controllers/api-docs.controller.ts b/backend/src/controllers/api-docs.controller.ts new file mode 100644 index 00000000..f20face5 --- /dev/null +++ b/backend/src/controllers/api-docs.controller.ts @@ -0,0 +1,991 @@ +import { Request, Response } from 'express'; + +class ApiDocsController { + async getApiDocs(req: Request, res: Response): Promise { + try { + const openApiSpec = { + openapi: "3.0.0", + info: { + title: "GitHub Value API", + version: "1.0.0", + description: "API for GitHub Value - Copilot ROI and adoption tracking" + }, + servers: [ + { + url: `${req.protocol}://${req.get('host')}/api`, + description: "Main API server" + } + ], + paths: { + "/survey": { + get: { + summary: "Get all surveys", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + }, + { + name: "team", + in: "query", + schema: { type: "string" }, + description: "Filter by team" + }, + { + name: "reasonLength", + in: "query", + schema: { type: "string" }, + description: "Filter by reason length" + }, + { + name: "since", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by start date (ISO format)" + }, + { + name: "until", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by end date (ISO format)" + }, + { + name: "status", + in: "query", + schema: { type: "string" }, + description: "Filter by status" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Survey" } + } + } + } + } + } + }, + post: { + summary: "Create a new survey", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/NewSurvey" } + } + } + }, + responses: { + "201": { + description: "Survey created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Survey" } + } + } + } + } + } + }, + "/survey/{id}": { + get: { + summary: "Get survey by ID", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + description: "Survey ID" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Survey" } + } + } + }, + "404": { + description: "Survey not found" + } + } + }, + put: { + summary: "Update survey by ID", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + description: "Survey ID" + } + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateSurvey" } + } + } + }, + responses: { + "200": { + description: "Survey updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Survey" } + } + } + }, + "404": { + description: "Survey not found" + } + } + }, + delete: { + summary: "Delete survey by ID", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + description: "Survey ID" + } + ], + responses: { + "204": { + description: "Survey deleted" + }, + "404": { + description: "Survey not found" + } + } + } + }, + "/survey/{id}/github": { + post: { + summary: "Update survey GitHub comment", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + description: "Survey ID" + } + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateSurvey" } + } + } + }, + responses: { + "201": { + description: "Survey GitHub comment updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Survey" } + } + } + } + } + } + }, + "/metrics": { + get: { + summary: "Get metrics", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + }, + { + name: "since", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by start date (ISO format)" + }, + { + name: "until", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by end date (ISO format)" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Metric" } + } + } + } + } + } + } + }, + "/metrics/totals": { + get: { + summary: "Get metrics totals", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + }, + { + name: "since", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by start date (ISO format)" + }, + { + name: "until", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by end date (ISO format)" + } + ], + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/seats": { + get: { + summary: "Get all seats", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Seat" } + } + } + } + } + } + } + }, + "/seats/activity": { + get: { + summary: "Get seats activity", + parameters: [ + { + name: "enterprise", + in: "query", + schema: { type: "string" }, + description: "Filter by enterprise" + }, + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + }, + { + name: "team", + in: "query", + schema: { type: "string" }, + description: "Filter by team" + }, + { + name: "since", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by start date (ISO format)" + }, + { + name: "until", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by end date (ISO format)" + }, + { + name: "seats", + in: "query", + schema: { type: "string", enum: ["0", "1"] }, + description: "Include seat data (1 to include)" + } + ], + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/seats/activity/totals": { + get: { + summary: "Get seats activity totals", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + }, + { + name: "since", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by start date (ISO format)" + }, + { + name: "until", + in: "query", + schema: { type: "string", format: "date-time" }, + description: "Filter by end date (ISO format)" + }, + { + name: "limit", + in: "query", + schema: { type: "integer" }, + description: "Limit results" + } + ], + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/seats/{id}": { + get: { + summary: "Get seat by ID or login", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Seat ID or login" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Seat" } + } + } + } + } + } + }, + "/teams": { + get: { + summary: "Get all teams", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Team" } + } + } + } + } + } + } + }, + "/members": { + get: { + summary: "Get all members", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string" }, + description: "Filter by organization" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Member" } + } + } + } + } + } + } + }, + "/members/search": { + get: { + summary: "Search members by login", + parameters: [ + { + name: "query", + in: "query", + required: true, + schema: { type: "string" }, + description: "Search query" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Member" } + } + } + } + } + } + } + }, + "/members/{login}": { + get: { + summary: "Get member by login", + parameters: [ + { + name: "login", + in: "path", + required: true, + schema: { type: "string" }, + description: "Member login" + }, + { + name: "exact", + in: "query", + schema: { type: "string", enum: ["true", "false"] }, + description: "Exact match ('true' for exact)" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Member" } + } + } + }, + "404": { + description: "Member not found" + } + } + } + }, + "/settings": { + get: { + summary: "Get all settings", + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + settings: { + type: "array", + items: { $ref: "#/components/schemas/Setting" } + } + } + } + } + } + } + } + }, + post: { + summary: "Create settings", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Setting" } + } + } + }, + responses: { + "201": { + description: "Settings created" + } + } + }, + put: { + summary: "Update settings", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Setting" } + } + } + }, + responses: { + "200": { + description: "Settings updated" + } + } + } + }, + "/settings/{name}": { + get: { + summary: "Get settings by name", + parameters: [ + { + name: "name", + in: "path", + required: true, + schema: { type: "string" }, + description: "Setting name" + } + ], + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Setting" } + } + } + }, + "404": { + description: "Setting not found" + } + } + }, + delete: { + summary: "Delete settings by name", + parameters: [ + { + name: "name", + in: "path", + required: true, + schema: { type: "string" }, + description: "Setting name" + } + ], + responses: { + "200": { + description: "Setting deleted" + } + } + } + }, + "/setup/registration/complete": { + get: { + summary: "Complete GitHub App registration", + parameters: [ + { + name: "code", + in: "query", + required: true, + schema: { type: "string" }, + description: "GitHub code" + } + ], + responses: { + "302": { + description: "Redirect to GitHub App installation page" + } + } + } + }, + "/setup/install/complete": { + get: { + summary: "Complete GitHub App installation", + responses: { + "302": { + description: "Redirect to home page" + } + } + } + }, + "/setup/install": { + get: { + summary: "Get GitHub App installation", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string" }, + owner: { type: "string" } + } + } + } + } + }, + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/setup/manifest": { + get: { + summary: "Get GitHub App manifest", + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/setup/existing-app": { + post: { + summary: "Add existing GitHub App", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["appId", "privateKey", "webhookSecret"], + properties: { + appId: { type: "string" }, + privateKey: { type: "string" }, + webhookSecret: { type: "string" } + } + } + } + } + }, + responses: { + "200": { + description: "Successful response" + }, + "400": { + description: "Missing required fields" + } + } + } + }, + "/setup/db": { + post: { + summary: "Set up database connection", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["uri"], + properties: { + uri: { type: "string" } + } + } + } + } + }, + responses: { + "200": { + description: "Database setup started" + } + } + } + }, + "/setup/status": { + get: { + summary: "Get setup status", + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/status": { + get: { + summary: "Get application status", + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/targets": { + get: { + summary: "Get target values", + responses: { + "200": { + description: "Successful response" + } + } + }, + post: { + summary: "Update target values", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/TargetValues" } + } + } + }, + responses: { + "200": { + description: "Target values updated" + } + } + } + }, + "/targets/calculate": { + get: { + summary: "Calculate target values", + parameters: [ + { + name: "org", + in: "query", + schema: { type: "string", default: "enterprise" }, + description: "Organization (defaults to 'enterprise')" + }, + { + name: "enableLogging", + in: "query", + schema: { type: "string", enum: ["true", "false"] }, + description: "Enable logging ('true' to enable)" + }, + { + name: "includeLogs", + in: "query", + schema: { type: "string", enum: ["true", "false"] }, + description: "Include logs in response ('true' to include)" + } + ], + responses: { + "200": { + description: "Successful response" + } + } + } + }, + "/docs": { + get: { + summary: "Get API documentation", + parameters: [ + { + name: "format", + in: "query", + schema: { type: "string", enum: ["json", "html"] }, + description: "Documentation format (html for interactive UI)" + } + ], + responses: { + "200": { + description: "Successful response" + } + } + } + } + }, + components: { + schemas: { + Survey: { + type: "object", + properties: { + id: { type: "integer" }, + status: { type: "string", enum: ["pending", "completed"] }, + hits: { type: "integer" }, + userId: { type: "string" }, + org: { type: "string" }, + repo: { type: "string" }, + prNumber: { type: "integer" }, + usedCopilot: { type: "boolean" }, + percentTimeSaved: { type: "number" }, + reason: { type: "string" }, + timeUsedFor: { type: "string" } + } + }, + NewSurvey: { + type: "object", + required: ["status", "userId", "org", "repo", "prNumber", "usedCopilot"], + properties: { + status: { type: "string", enum: ["pending", "completed"] }, + userId: { type: "string" }, + org: { type: "string" }, + repo: { type: "string" }, + prNumber: { type: "integer" }, + usedCopilot: { type: "boolean" }, + percentTimeSaved: { type: "number" }, + reason: { type: "string" }, + timeUsedFor: { type: "string" } + } + }, + UpdateSurvey: { + type: "object", + properties: { + status: { type: "string", enum: ["pending", "completed"] }, + usedCopilot: { type: "boolean" }, + percentTimeSaved: { type: "number" }, + reason: { type: "string" }, + timeUsedFor: { type: "string" } + } + }, + Metric: { + type: "object", + properties: { + org: { type: "string" }, + date: { type: "string", format: "date-time" }, + completions: { type: "integer" }, + suggestions: { type: "integer" }, + acceptances: { type: "integer" } + } + }, + Seat: { + type: "object", + properties: { + assignee_id: { type: "integer" }, + assignee_login: { type: "string" }, + last_activity_at: { type: "string", format: "date-time" }, + last_activity_editor: { type: "string" }, + created_at: { type: "string", format: "date-time" }, + assignee: { + type: "object", + properties: { + login: { type: "string" }, + id: { type: "integer" }, + avatar_url: { type: "string" } + } + } + } + }, + Team: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + slug: { type: "string" }, + description: { type: "string" }, + privacy: { type: "string" }, + members_count: { type: "integer" } + } + }, + Member: { + type: "object", + properties: { + login: { type: "string" }, + id: { type: "integer" }, + name: { type: "string" }, + avatar_url: { type: "string" }, + team: { type: "string" }, + org: { type: "string" }, + seat: { $ref: "#/components/schemas/Seat" } + } + }, + Setting: { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string" }, + secure: { type: "boolean" } + } + }, + TargetValues: { + type: "object", + properties: { + devCostPerYear: { type: "string" }, + developerCount: { type: "string" }, + hoursPerYear: { type: "string" }, + percentTimeSaved: { type: "string" }, + percentCoding: { type: "string" } + } + } + } + } + }; + + // Add a simplified HTML UI option + if (req.query.format === 'html') { + res.setHeader('Content-Type', 'text/html'); + res.status(200).send(` + + + + GitHub Value API Documentation + + + + + +
+ + + + + `); + return; + } + + res.status(200).json(openApiSpec); + } catch { + res.status(500).json({ error: "Failed to retrieve API documentation" }); + } + } +} + +export default new ApiDocsController(); diff --git a/backend/src/controllers/seats.controller.ts b/backend/src/controllers/seats.controller.ts index f420bf21..3e65abb8 100644 --- a/backend/src/controllers/seats.controller.ts +++ b/backend/src/controllers/seats.controller.ts @@ -7,19 +7,28 @@ class SeatsController { try { const seats = await SeatsService.getAllSeats(org); res.status(200).json(seats); - } catch (error) { - res.status(500).json(error); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + res.status(500).json({ error: errorMessage }); } } async getSeat(req: Request, res: Response): Promise { const { id } = req.params; - const idNumber = Number(id); + const { since, until, org } = req.query as { [key: string]: string | undefined }; + try { - const seat = isNaN(idNumber) ? await SeatsService.getAssigneeByLogin(id) : await SeatsService.getAssignee(idNumber); + const sanitizedOrg = typeof org === 'string' ? org : undefined; + const params = { since, until, org: sanitizedOrg }; + + // Use our new unified getSeat method that handles both ID and login + // Pass the ID directly without conversion - the service will handle it + const seat = await SeatsService.getSeat(id, params); + res.status(200).json(seat); - } catch (error) { - res.status(500).json(error); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + res.status(500).json({ error: errorMessage }); } } @@ -38,8 +47,9 @@ class SeatsController { precision: precision as 'hour' | 'day' }); res.status(200).json(activityDays); - } catch (error) { - res.status(500).json(error); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + res.status(500).json({ error: errorMessage }); } } } diff --git a/backend/src/controllers/setup.controller.ts b/backend/src/controllers/setup.controller.ts index 9280ce9a..e8abb580 100644 --- a/backend/src/controllers/setup.controller.ts +++ b/backend/src/controllers/setup.controller.ts @@ -84,7 +84,7 @@ class SetupController { async getStatus(req: Request, res: Response) { try { const statusService = new StatusService(); - const status = await statusService.getStatus(); + const status = await statusService.getStatus(req); res.json(status); } catch (error) { res.status(500).json(error); diff --git a/backend/src/controllers/survey.controller.ts b/backend/src/controllers/survey.controller.ts index 120fff0d..da6b8fe5 100644 --- a/backend/src/controllers/survey.controller.ts +++ b/backend/src/controllers/survey.controller.ts @@ -64,37 +64,20 @@ class SurveyController { } async getAllSurveys(req: Request, res: Response): Promise { - const { org, team, reasonLength, since, until, status } = req.query as { [key: string]: string | undefined };; try { - const dateFilter: mongoose.FilterQuery<{ - $gte: Date; - $lte: Date; - }> = {}; - if (since) { - dateFilter.$gte = new Date(since); - } - if (until) { - dateFilter.$lte = new Date(until); - } - - const query = { - filter: { - ...(org ? { org: String(org) } : {}), - ...(team ? { team: String(team) } : {}), - ...(reasonLength ? { $expr: { $and: [{ $gt: [{ $strLenCP: { $ifNull: ['$reason', ''] } }, 40] }, { $ne: ['$reason', null] }] } } : {}), - ...(Object.keys(dateFilter).length > 0 ? { createdAt: dateFilter } : {}), - ...(status ? { status } : {}), - }, - projection: { - _id: 0, - __v: 0, - } - }; - - const Survey = mongoose.model('Survey'); - const surveys = await Survey.find(query.filter, query.projection); + const { org, team, reasonLength, since, until, status, userId } = req.query as { [key: string]: string | undefined }; + + const surveys = await surveyService.getAllSurveys({ + org, + team, + reasonLength, + since, + until, + status, + userId + }); + res.status(200).json(surveys); - } catch (error) { res.status(500).json(error); } diff --git a/backend/src/controllers/target.controller.ts b/backend/src/controllers/target.controller.ts index ce68362c..2b68b338 100644 --- a/backend/src/controllers/target.controller.ts +++ b/backend/src/controllers/target.controller.ts @@ -1,5 +1,7 @@ import { Request, Response } from 'express'; import TargetValuesService from '../services/target.service.js'; +import { TargetCalculationService } from '../services/target-calculation-service.js'; +import logger from '../services/logger.js'; class TargetValuesController { async getTargetValues(req: Request, res: Response): Promise { @@ -7,6 +9,7 @@ class TargetValuesController { const targetValues = await TargetValuesService.getTargetValues(); res.status(200).json(targetValues); } catch (error) { + logger.error('Error getting target values:', error); res.status(500).json(error); } } @@ -16,9 +19,41 @@ class TargetValuesController { const updatedTargetValues = await TargetValuesService.updateTargetValues(req.body); res.status(200).json(updatedTargetValues); } catch (error) { + logger.error('Error updating target values:', error); res.status(500).json(error); } } + + /** + * Calculate targets based on current metrics, adoption, and survey data + * @route GET /targets/calculate + */ + async calculateTargetValues(req: Request, res: Response): Promise { + try { + // Only use org if it's explicitly passed in the query parameters + const org = req.query.org ? String(req.query.org) : null; + const enableLogging = req.query.enableLogging === 'true'; + const includeLogsInResponse = req.query.includeLogs === 'true'; + + + // Use the static method from TargetCalculationService to avoid instantiation issues + const result = await TargetCalculationService.fetchAndCalculateTargets( + org, // Pass null if no org was provided + enableLogging, + includeLogsInResponse + ); + + // Check if we have logs before sending the response + if (includeLogsInResponse) { + logger.info(`Response will include ${result.logs?.length || 0} logs`); + } + + res.status(200).json(result); + } catch (error) { + logger.error('Error calculating target values:', error); + res.status(500).json({ error: `Failed to calculate target values: ${error}` }); + } + } } export default new TargetValuesController(); diff --git a/backend/src/controllers/teams.controller.ts b/backend/src/controllers/teams.controller.ts index 41edac7a..8b08d50a 100644 --- a/backend/src/controllers/teams.controller.ts +++ b/backend/src/controllers/teams.controller.ts @@ -33,7 +33,7 @@ class TeamsController { async getMemberByLogin(req: Request, res: Response): Promise { try { const { login } = req.params; - const member = teamsService.getMemberByLogin(login); + const member = await teamsService.getMemberByLogin(login); if (member) { res.json(member); } else { @@ -43,6 +43,20 @@ class TeamsController { res.status(500).json(error); } } + + async searchMembersByLogin(req: Request, res: Response): Promise { + try { + const { query } = req.query; + if (!query || typeof query !== 'string') { + res.status(400).json({ message: 'Invalid query parameter' }); + return; + } + const members = await teamsService.searchMembersByLogin(query); + res.json(members); + } catch (error) { + res.status(500).json(error); + } + } } export default new TeamsController(); \ No newline at end of file diff --git a/backend/src/controllers/webhook.controller.ts b/backend/src/controllers/webhook.controller.ts index 0195e5e8..af4a0cf0 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -4,15 +4,26 @@ import surveyService from '../services/survey.service.js'; import app from '../index.js'; import teamsService from '../services/teams.service.js'; import { Endpoints } from '@octokit/types'; +import duplicateService from '../services/duplicate.service.js'; export const setupWebhookListeners = (github: App) => { github.webhooks.onAny(async ({ id, name, payload }) => { + if (await duplicateService.isDuplicate(id)) { + logger.info('Duplicate webhook event skipped', { id, name }); + return; + } + await duplicateService.register(id); app.github.webhookPingReceived = true; logger.debug(`GitHub Webhook event`, { id, name, payload }); logger.info(`GitHub Webhook event`, { id, name }); }); - github.webhooks.on(["pull_request.opened"], async ({ octokit, payload }) => { + github.webhooks.on(["pull_request.opened"], async ({ id, octokit, payload }) => { + if (await duplicateService.isDuplicate(id)) { + logger.info('Duplicate PR event skipped', { id }); + return; + } + await duplicateService.register(id); try { if (payload.pull_request.user.type === 'Bot') { logger.debug(`Ignoring PR from bot user: ${payload.pull_request.user.login}`); @@ -20,6 +31,7 @@ export const setupWebhookListeners = (github: App) => { } const survey = await surveyService.createSurvey({ + id: Date.now(), // or some other numeric ID generation approach status: 'pending', hits: 0, userId: payload.pull_request.user.login, @@ -65,7 +77,12 @@ export const setupWebhookListeners = (github: App) => { }; }); - github.webhooks.on("team", async ({ payload }) => { + github.webhooks.on("team", async ({ id, payload }) => { + if (await duplicateService.isDuplicate(id)) { + logger.info('Duplicate team event skipped', { id }); + return; + } + await duplicateService.register(id); try { switch (payload.action) { case 'created': @@ -81,7 +98,12 @@ export const setupWebhookListeners = (github: App) => { } }); - github.webhooks.on("membership", async ({ payload }) => { + github.webhooks.on("membership", async ({ id, payload }) => { + if (await duplicateService.isDuplicate(id)) { + logger.info('Duplicate membership event skipped', { id }); + return; + } + await duplicateService.register(id); const queryService = app.github.queryService; if (!queryService) throw new Error('No query service found'); try { @@ -100,7 +122,12 @@ export const setupWebhookListeners = (github: App) => { } }); - github.webhooks.on("member", async ({ payload }) => { + github.webhooks.on("member", async ({ id, payload }) => { + if (await duplicateService.isDuplicate(id)) { + logger.info('Duplicate member event skipped', { id }); + return; + } + await duplicateService.register(id); try { if (payload.member) { switch (payload.action) { diff --git a/backend/src/database.ts b/backend/src/database.ts index d59f765e..6f4ef9be 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -194,6 +194,10 @@ class Database { }, { timestamps: true, }); + // Add to Member schema + memberSchema.index({ login: 1 }); // Standalone index for login lookups + memberSchema.index({ id: 1 }); // Standalone index for id lookups + memberSchema.index({ org: 1 }); // Standalone index for org lookups memberSchema.index({ org: 1, login: 1, id: 1 }, { unique: true }); memberSchema.index({ seat: 1 }); memberSchema.index({ updatedAt: -1 }); @@ -237,6 +241,11 @@ class Database { seatsSchema.index({ org: 1, queryAt: 1, last_activity_at: -1 }); seatsSchema.index({ org: 1, team: 1, queryAt: 1, assignee_id: 1 }, { unique: true }); + // Add to Seats schema (if not already present) + seatsSchema.index({ assignee_login: 1 }); + seatsSchema.index({ assignee_id: 1 }); + seatsSchema.index({ org: 1 }); + mongoose.model('Seats', seatsSchema); const adoptionSchema = new Schema({ diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 0dd35f93..a31a9d1a 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -7,6 +7,7 @@ import metricsController from '../controllers/metrics.controller.js'; import teamsController from '../controllers/teams.controller.js'; import targetValuesController from '../controllers/target.controller.js'; import adoptionController from '../controllers/adoption.controller.js'; +import apiDocsController from '../controllers/api-docs.controller.js'; import mongoSanitize from 'express-mongo-sanitize'; const router = Router(); @@ -32,8 +33,10 @@ router.get('/seats/activity', adoptionController.getAdoptions); router.get('/seats/activity/totals', adoptionController.getAdoptionTotals); router.get('/seats/:id', SeatsController.getSeat); +// The order matters - more specific routes first router.get('/teams', teamsController.getAllTeams); router.get('/members', teamsController.getAllMembers); +router.get('/members/search', teamsController.searchMembersByLogin); // This needs to be before the dynamic route router.get('/members/:login', teamsController.getMemberByLogin); router.get('/settings', settingsController.getAllSettings); @@ -54,6 +57,11 @@ router.get('/status', setupController.getStatus); router.get('/targets', targetValuesController.getTargetValues); router.post('/targets', targetValuesController.updateTargetValues); +// Add the new route for target calculation +router.get('/targets/calculate', targetValuesController.calculateTargetValues); + +// Add the new API documentation endpoint +router.get('/docs', apiDocsController.getApiDocs); router.get('*', (req: Request, res: Response) => { res.status(404).send('Route not found'); diff --git a/backend/src/routes/status.route.ts b/backend/src/routes/status.route.ts new file mode 100644 index 00000000..843fa765 --- /dev/null +++ b/backend/src/routes/status.route.ts @@ -0,0 +1,19 @@ +import express from 'express'; +import StatusService from '../services/status.service.js'; +import logger from '../services/logger.js'; + +const router = express.Router(); +const statusService = new StatusService(); + +router.get('/', async (req, res) => { + try { + // Pass the request object to getStatus + const status = await statusService.getStatus(req); + res.json(status); + } catch (error) { + logger.error('Error fetching status:', error); + res.status(500).json({ error: 'Failed to fetch status' }); + } +}); + +export default router; diff --git a/backend/src/services/duplicate.service.ts b/backend/src/services/duplicate.service.ts new file mode 100644 index 00000000..827e69b8 --- /dev/null +++ b/backend/src/services/duplicate.service.ts @@ -0,0 +1,21 @@ +class DuplicateService { + private processed = new Set(); + private queue: string[] = []; + private readonly maxEntries = 50; + + async isDuplicate(id: string) { + return this.processed.has(id); + } + + async register(id: string) { + if (!this.processed.has(id)) { + this.processed.add(id); + this.queue.push(id); + if (this.queue.length > this.maxEntries) { + const oldest = this.queue.shift()!; + this.processed.delete(oldest); + } + } + } +} +export default new DuplicateService(); diff --git a/backend/src/services/seats.service.ts b/backend/src/services/seats.service.ts index e567833a..32dc4a94 100644 --- a/backend/src/services/seats.service.ts +++ b/backend/src/services/seats.service.ts @@ -3,7 +3,6 @@ import { SeatType } from "../models/seats.model.js"; import { components } from "@octokit/openapi-types"; import mongoose from 'mongoose'; import { MemberActivityType, MemberType } from 'models/teams.model.js'; -import fs from 'fs'; import adoptionService from './adoption.service.js'; import logger from './logger.js'; @@ -27,6 +26,13 @@ type MemberDailyActivity = { }; }; +interface MemberDocument { + _id: mongoose.Types.ObjectId; + id: number; + login: string; + // [key: string]: any; // For other properties +} + class SeatsService { async getAllSeats(org?: string) { const Member = mongoose.model('Member'); @@ -46,19 +52,48 @@ class SeatsService { return seats; } - async getAssignee(id: number) { + /** + * Retrieves all seat activity records for a user by their GitHub ID + * @param id GitHub user ID + * @param params Optional parameters for filtering (since, until) + */ + async getAssignee(id: number, params: { since?: string; until?: string } = {}) { const Seats = mongoose.model('Seats'); const Member = mongoose.model('Member'); + // First find the member document by GitHub user ID const member = await Member.findOne({ id }).sort({ org: -1 }); //this temporarily resolves a bug where one org fails but the other one succeeds if (!member) { - throw `Member with id ${id} not found` + throw new Error(`Member with id ${id} not found`); // Updated to throw a proper Error } - return Seats.find({ - assignee: member._id - }) - .lean() + // Build query with date range filtering if provided + // Using a more specific type instead of any + interface SeatQuery { + assignee: mongoose.Types.ObjectId; + createdAt?: { + $gte?: Date; + $lte?: Date; + }; + } + + const query: SeatQuery = { + assignee: member._id // This is the MongoDB ObjectId that links to the Member document + }; + + // Add date range filters if provided + if (params.since || params.until) { + query.createdAt = { + ...(params.since && { $gte: new Date(params.since) }), + ...(params.until && { $lte: new Date(params.until) }) + }; + } + + // Query all seat activity records where the assignee field matches the member's _id + // This returns the complete activity history for this user + return Seats.find(query) + .sort({ createdAt: 1 }) // Sort by creation time ascending (oldest first) + .lean() // Convert Mongoose documents to plain JavaScript objects .populate({ path: 'assignee', // Link to Member model 👤 model: Member, @@ -66,18 +101,38 @@ class SeatsService { }); } - async getAssigneeByLogin(login: string) { + /** + * Retrieves all seat activity records for a user by their GitHub login (username) + * @param login GitHub username + * @param params Optional parameters for filtering (since, until) + */ + async getAssigneeByLogin(login: string, params: { since?: string; until?: string } = {}) { const Seats = mongoose.model('Seats'); const Member = mongoose.model('Member'); + // First find the member document by GitHub username const member = await Member.findOne({ login }); if (!member) { throw `Member with id ${login} not found` } - return Seats.find({ + // Build query with date range filtering if provided + const query: mongoose.FilterQuery = { assignee: member._id - }) + }; + + // Add date range filters if provided + if (params.since || params.until) { + query.createdAt = { + ...(params.since && { $gte: new Date(params.since) }), + ...(params.until && { $lte: new Date(params.until) }) + }; + } + + // Query all seat activity records where the assignee field matches the member's _id + // This returns the complete activity history for this user + return Seats.find(query) + .sort({ createdAt: 1 }) // Sort by creation time ascending (oldest first) .lean() .populate({ path: 'assignee', // Link to Member model 👤 @@ -86,6 +141,95 @@ class SeatsService { }); } + /** + * Improved method to find seat information by either ID or login + * @param identifier Either a numeric ID or string login + * @param params Optional parameters for filtering (since, until, org) + */ + async getSeat(identifier: string | number, params: { since?: string; until?: string; org?: string } = {}) { + const Seats = mongoose.model('Seats'); + const Member = mongoose.model('Member'); + + try { + // Determine if identifier is numeric + const isNumeric = !isNaN(Number(identifier)) && String(Number(identifier)) === String(identifier); + let numericId: number | null = null; + + // If it's a login, look up the ID first + if (!isNumeric) { + // Ensure identifier is treated as string before calling replace + const identifierString = String(identifier); + + try { + // Find the member by login - exact match with explicit type casting + const member = await Member.findOne({ login: identifierString }).lean() as MemberDocument | null; + + if (!member) { + // Try case-insensitive search as a fallback + const regex = new RegExp(`^${identifierString.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}$`, 'i'); + const memberCaseInsensitive = await Member.findOne({ + login: regex + }).lean() as MemberDocument | null; + + if (!memberCaseInsensitive) { + return []; // Return empty array if no member found + } + + // Now TypeScript knows memberCaseInsensitive has these properties + numericId = memberCaseInsensitive.id; + } else { + // Now TypeScript knows member has these properties + numericId = member.id; + } + } catch { + return []; // Return empty array on error + } + } else { + numericId = Number(identifier); + } + + const query: mongoose.FilterQuery = { assignee_id: numericId }; + + if (params.org) { + query.org = { $eq: params.org }; + } + + if (params.since || params.until) { + query.createdAt = {}; + if (params.since) { + query.createdAt.$gte = new Date(params.since); + } + if (params.until) { + query.createdAt.$lte = new Date(params.until); + } + } + + // Execute the query + const results = await Seats.find(query) + .sort({ createdAt: 1 }) + .populate({ + path: 'assignee', + model: Member, + select: 'login id avatar_url name url html_url' + }) + .lean() + .exec(); // Explicitly call exec() + + logger.debug(`Query complete. Found ${results?.length || 0} seat records`); + + return results || []; + } catch (error: unknown) { + logger.error('========== SEAT LOOKUP ERROR =========='); + logger.error('Error retrieving seat data for %s:', identifier, error); + // Safe access to stack property + logger.error(`Stack trace:`, error instanceof Error ? error.stack : 'No stack trace available'); + logger.error('======================================='); + + // Return empty results rather than throwing error + return []; + } + } + async insertSeats(org: string, queryAt: Date, data: SeatEntry[], team?: string) { const Members = mongoose.model('Member'); const Seats = mongoose.model('Seats'); @@ -379,8 +523,6 @@ class SeatsService { .sort(([dateA], [dateB]) => new Date(dateA).getTime() - new Date(dateB).getTime()) ); - fs.writeFileSync('sortedActivityDays.json', JSON.stringify(sortedActivityDays, null, 2), 'utf-8'); - return sortedActivityDays; } diff --git a/backend/src/services/smee.ts b/backend/src/services/smee.ts index 670284c8..1d55baf4 100644 --- a/backend/src/services/smee.ts +++ b/backend/src/services/smee.ts @@ -1,6 +1,6 @@ import logger from "./logger.js"; import Client from "smee-client"; -import { EventSource } from "eventsource"; +import { ErrorEvent, EventSource } from "eventsource"; export interface WebhookServiceOptions { url?: string, @@ -29,8 +29,13 @@ class WebhookService { } } - if (!this.options.url && this.options.url !== '') { + // Only create a new webhook URL if one isn't provided at all + // This check was allowing new URL creation even when an empty string was set + if (!this.options.url) { + logger.info('No webhook URL provided, creating new Smee channel'); this.options.url = await this.createSmeeWebhookUrl(); + } else { + logger.info(`Using existing webhook URL: ${this.options.url}`); } const parsedUrl = new URL(this.options.url); @@ -43,12 +48,27 @@ class WebhookService { source: this.options.url, target: `http://localhost:${this.options.port}${this.options.path}`, logger: { - info: (msg: string, ...args) => logger.info('Smee', msg, ...args), - error: (msg: string, ...args) => logger.error('Smee', msg, ...args), + info: (msg: string, ...args) => + logger.info('Smee', msg, ...args), + error: (msg: string, ...args) => { + if (typeof msg === 'string' && msg.includes('ECONNRESET')) { + logger.warn('Smee', 'Connection reset by peer, auto‑reconnecting'); + } else { + logger.error('Smee', msg, ...args); + } + } + } + }); + this.eventSource = await this.smee.start(); + // also catch any lower‑level EventSource errors + this.eventSource.addEventListener('error', (err: ErrorEvent) => { + const m = err?.message || err; + if (typeof m === 'string' && m.includes('ECONNRESET')) { + logger.warn('Smee EventSource', 'read ECONNRESET, reconnecting'); + } else { + logger.error('Smee EventSource', err); } }); - - this.eventSource = await this.smee.start() } catch { logger.error('Failed to create Smee client'); }; diff --git a/backend/src/services/status.service.ts b/backend/src/services/status.service.ts index a489b9d8..20498fea 100644 --- a/backend/src/services/status.service.ts +++ b/backend/src/services/status.service.ts @@ -1,6 +1,7 @@ import mongoose from "mongoose"; import app from "../index.js"; import { Endpoints } from "@octokit/types"; +import { Request } from "express"; export interface StatusType { github?: boolean; @@ -13,16 +14,37 @@ export interface StatusType { repos: Endpoints["GET /app/installations"]["response"]["data"]; }[]; surveyCount: number; + auth?: { + user?: string; + email?: string; + authenticated: boolean; + groups?: string[]; + headers?: string[]; // Add this to store header names + }; } class StatusService { - constructor() { - - } + + constructor() { } - async getStatus(): Promise { + async getStatus(req?: Request): Promise { const status = {} as StatusType; + // Add authentication information if request is provided + if (req) { + const user = req.headers['x-auth-request-user'] as string; + const email = req.headers['x-auth-request-email'] as string; + const groups = req.headers['x-auth-request-groups'] as string[]; + + status.auth = { + user, + email, + authenticated: !!user, + groups, + headers: Object.keys(req.headers) // Add all header names as an array + }; + } + const Seats = mongoose.model('Seats'); const oldestSeat = await Seats.findOne().sort({ createdAt: 1 }); diff --git a/backend/src/services/survey.service.ts b/backend/src/services/survey.service.ts index eeffc5f5..6ad11278 100644 --- a/backend/src/services/survey.service.ts +++ b/backend/src/services/survey.service.ts @@ -1,8 +1,25 @@ -import { SurveyType } from "../models/survey.model.js"; import mongoose from 'mongoose'; import SequenceService from './sequence.service.js'; import logger from "./logger.js"; +// Define the SurveyType interface here instead of importing it +export interface SurveyType { + id: number; + userId: string; + org?: string; + repo?: string; + prNumber?: number; + usedCopilot?: boolean; + percentTimeSaved?: number; + reason?: string; + timeUsedFor?: string; + kudos?: number; + status?: string; + hits?: number; + createdAt?: Date; + updatedAt?: Date; +} + class SurveyService { async createSurvey(survey: SurveyType) { @@ -48,6 +65,70 @@ class SurveyService { } }).sort({ updatedAt: -1 }).limit(20).exec(); } + + /** + * Get all surveys based on filtering criteria + */ + async getAllSurveys(params: { + org?: string; + team?: string; + reasonLength?: string; + since?: string; + until?: string; + status?: string; + userId?: string; + }) { + const { org, team, reasonLength, since, until, status, userId } = params; + + const dateFilter: mongoose.FilterQuery = {}; + + // Validate the date strings before creating Date objects + if (since) { + try { + const sinceDate = new Date(since); + // Check if the date is valid + if (!isNaN(sinceDate.getTime())) { + dateFilter.$gte = sinceDate; + } else { + logger.warn(`Invalid 'since' date parameter: ${since}`); + } + } catch (error) { + logger.error(`Error parsing 'since' date: ${since}`, error); + } + } + + if (until) { + try { + const untilDate = new Date(until); + // Check if the date is valid + if (!isNaN(untilDate.getTime())) { + dateFilter.$lte = untilDate; + } else { + logger.warn(`Invalid 'until' date parameter: ${until}`); + } + } catch (error) { + logger.error(`Error parsing 'until' date: ${until}`, error); + } + } + + const query = { + filter: { + ...(org ? { org: String(org) } : {}), + ...(team ? { team: String(team) } : {}), + ...(userId ? { userId: String(userId) } : {}), + ...(reasonLength ? { $expr: { $and: [{ $gt: [{ $strLenCP: { $ifNull: ['$reason', ''] } }, 40] }, { $ne: ['$reason', null] }] } } : {}), + ...(Object.keys(dateFilter).length > 0 ? { createdAt: dateFilter } : {}), + ...(status ? { status } : {}), + }, + projection: { + _id: 0, + __v: 0, + } + }; + + const Survey = mongoose.model('Survey'); + return Survey.find(query.filter, query.projection); + } } export default new SurveyService(); \ No newline at end of file diff --git a/backend/src/services/target-calculation-service.ts b/backend/src/services/target-calculation-service.ts new file mode 100644 index 00000000..d42c722b --- /dev/null +++ b/backend/src/services/target-calculation-service.ts @@ -0,0 +1,988 @@ +import { SettingsType } from './settings.service.js'; +import adoptionService, { AdoptionType } from './adoption.service.js'; +import metricsService from './metrics.service.js'; +import { MetricDailyResponseType } from "../models/metrics.model.js"; +import copilotSurveyService from './survey.service.js'; +import { SurveyType } from './survey.service.js'; // Import from survey.service.js instead +import app from '../index.js'; +import dayjs from 'dayjs'; +import util from 'util'; +import logger from './logger.js'; + +// Define types for calculation logging +interface CalcLogType { + name: string; + inputs: Record; + formula: string; + result: unknown; +} + +// Carefully typed interfaces based on actual service data structures +interface Target { + current: number; + target: number; + max: number; +} + +interface Targets { + org: { + seats: Target; + adoptedDevs: Target; + monthlyDevsReportingTimeSavings: Target; + percentOfSeatsReportingTimeSavings: Target; + percentOfSeatsAdopted: Target; + percentOfMaxAdopted: Target; + }; + user: { + dailySuggestions: Target; + dailyAcceptances: Target; + dailyChatTurns: Target; + dailyDotComChats: Target; + weeklyPRSummaries: Target; + weeklyTimeSavedHrs: Target; + }; + impact: { + monthlyTimeSavingsHrs: Target; + annualTimeSavingsAsDollars: Target; + productivityOrThroughputBoostPercent: Target; + }; + [key: string]: Record; +} + +export class TargetCalculationService { + // Class variables to store fetched data + settings!: SettingsType; + adoptions!: AdoptionType[]; + metricsDaily!: MetricDailyResponseType[]; + metricsWeekly!: MetricDailyResponseType[]; + surveysWeekly!: SurveyType[]; + surveysMonthly!: SurveyType[]; + + // Flag to enable/disable calculation logging + debugLogging: boolean = false; + + // Replace individual boolean flags with a Set to track logged calculation names + private loggedCalculations: Set = new Set(); + + // Tracks calculation readiness + dataFetched: boolean = false; + + // Collection of logs to return with the response + calculationLogs: CalcLogType[] = []; + + /** + * Log calculation details if debug logging is enabled + * Each calculation name will only be logged once + */ + private logCalculation(name: string, inputs: Record, formula: string, result: unknown): void { + // Only log if we haven't logged this calculation name before + if (!this.loggedCalculations.has(name)) { + // Mark this calculation name as logged + this.loggedCalculations.add(name); + + const logEntry: CalcLogType = { + name, + inputs, + formula, + result + }; + + // Store the log entry if debug logging is enabled + if (this.debugLogging) { + this.calculationLogs.push(logEntry); + + // Also pretty print to console + logger.info(` +========== CALCULATION: ${name} ========== +INPUTS: +${util.inspect(inputs, { depth: null, colors: false, compact: false })} + +FORMULA/ALGORITHM: + ${formula} + +RESULT: + ${util.inspect(result, { depth: null, colors: false, compact: false })} +======================================== +`); + } + } + } + + // Reset the logged calculations set when starting a new calculation run + private resetLogging(): void { + this.loggedCalculations.clear(); + this.calculationLogs = []; + } + + /** + * Fetch and store all calculation data from services + */ + async fetchCalculationData(org: string | null, referenceDate: Date = new Date()): Promise { + // Format date ranges + const now = dayjs(referenceDate); + const oneDayAgo = now.subtract(1, 'day').toDate(); + const sevenDaysAgo = now.subtract(7, 'days').toDate(); + const thirtyDaysAgo = now.subtract(30, 'days').toDate(); + + // Create common params without org + const baseMetricsParams: { since: string; until: string; org?: string } = { + since: oneDayAgo.toISOString(), + until: now.toISOString() + }; + + const weeklyMetricsParams: { since: string; until: string; org?: string } = { + since: sevenDaysAgo.toISOString(), + until: now.toISOString() + }; + + const weeklySurveysParams: { since: string; until: string; org?: string } = { + since: sevenDaysAgo.toISOString(), + until: now.toISOString() + }; + + const monthlySurveysParams: { since: string; until: string; org?: string } = { + since: thirtyDaysAgo.toISOString(), + until: now.toISOString() + }; + + // Add org parameter only if it's provided + if (org) { + baseMetricsParams.org = org; + weeklyMetricsParams.org = org; + weeklySurveysParams.org = org; + monthlySurveysParams.org = org; + } + + this.logCalculation( + 'Calculate Parameters', + { + baseMetricsParams: baseMetricsParams, + weeklyMetricsParams: weeklyMetricsParams, + monthlySurveysParams: monthlySurveysParams + }, + 'select the various metrics for last week and the last 30 days ago', + {} // Replace 'result' with empty object as it's not defined + ); + // Fetch all required data in parallel + [ + this.settings, + this.adoptions, + this.metricsDaily, + this.metricsWeekly, + this.surveysWeekly, + this.surveysMonthly + ] = await Promise.all([ + app.settingsService.getAllSettings(), // Use app-level settings service + adoptionService.getAllAdoptions2({ + filter: { + // Only set enterprise if no org is provided + ...(org ? { org } : { enterprise: 'enterprise' }), + // Only get adoption data from the last 7 days + date: { + $gte: sevenDaysAgo, + $lte: now.toDate() + }, + }, + projection: { + // Only select fields needed for calculations + totalSeats: 1, + totalActive: 1, + totalInactive: 1, + date: 1, + org: 1, + enterprise: 1 + } + }), + metricsService.getMetrics(baseMetricsParams), + metricsService.getMetrics(weeklyMetricsParams), + copilotSurveyService.getAllSurveys(weeklySurveysParams), + copilotSurveyService.getAllSurveys(monthlySurveysParams) + ]); + + this.dataFetched = true; + } + + // === UTILITY CALCULATION METHODS === + + /** + * Round a number to a specified number of decimal places + * @param value The number to round + * @param decimals The number of decimal places (default: 1) + */ + private roundToDecimal(value: number, decimals: number = 1): number { + const factor = Math.pow(10, decimals); + return Math.round(value * factor) / factor; + } + + /** + * Calculate percentage with protection against division by zero + */ + calculatePercentage(numerator: number, denominator: number): number { + if (denominator === 0) { + return 0; + } + // Calculate and round to one decimal place + return this.roundToDecimal((numerator / denominator) * 100); + } + + /** + * Get distinct users from an array of surveys + */ + getDistinctSurveyUsers(surveys: SurveyType[]): string[] { + return [...new Set(surveys.map(survey => survey.userId))]; + } + + // === ORG-LEVEL CALCULATIONS === + + /** + * Calculate seats target value using adoption data and settings + */ + calculateSeats(): Target { + // Replicate existing logic from target.service.ts + const topAdoptions = this.adoptions + .sort((a, b) => b.totalActive - a.totalActive) + .slice(0, 10); + + const totalSeats = topAdoptions.reduce((sum, adoption) => sum + adoption.totalSeats, 0); + const avgTotalSeats = Math.round(totalSeats / (topAdoptions.length || 1)); + + // Convert developerCount to number to ensure the correct type + const developerCount = typeof this.settings.developerCount === 'string' + ? parseInt(this.settings.developerCount, 10) + : (this.settings.developerCount || 0); + + const result = { + current: avgTotalSeats, + target: avgTotalSeats, + max: developerCount + }; + + this.logCalculation( + 'Calculate SEATS', + { + topAdoptions: topAdoptions.map(a => ({ totalSeats: a.totalSeats, totalActive: a.totalActive })), + developerCount: this.settings.developerCount, + adoptionsCount: this.adoptions.length + }, + 'Sort adoptions by totalActive, take top 10, average totalSeats, set current = target = avgTotalSeats, max = developerCount', + result + ); + + return result; + } + + /** + * Calculate adopted developers using adoption data + */ + calculateAdoptedDevs(): Target { + const topAdoptions = this.adoptions + .sort((a, b) => b.totalActive - a.totalActive) + .slice(0, 10); + + const totalActive = topAdoptions.reduce((sum, adoption) => sum + adoption.totalActive, 0); + const avgTotalActive = Math.round(totalActive / (topAdoptions.length || 1)); + + // Convert developerCount to number + const developerCount = typeof this.settings.developerCount === 'string' + ? parseInt(this.settings.developerCount, 10) + : (this.settings.developerCount || 0); + + const result = { + current: avgTotalActive, + target: avgTotalActive, + max: developerCount + }; + + this.logCalculation( + 'ADOPTED DEVS', + { + topAdoptions: topAdoptions.map(a => ({ totalSeats: a.totalSeats, totalActive: a.totalActive })), + totalActive: totalActive, + avgTotalActive: avgTotalActive, + developerCount: this.settings.developerCount, + adoptionsRecordCount: this.adoptions.length + }, + 'Get total active developers from top 10 orgs, calculate average (totalActive / topAdoptions.length), set current = target = avgTotalActive, max = developerCount', + result + ); + + return result; + } + + /** + * Calculate monthly devs reporting time savings from survey data + */ + calculateMonthlyDevsReportingTimeSavings(): Target { + const distinctUsers = this.getDistinctSurveyUsers(this.surveysMonthly); + + // Convert developerCount to number + const developerCount = typeof this.settings.developerCount === 'string' + ? parseInt(this.settings.developerCount, 10) + : (this.settings.developerCount || 0); + + const result = { + current: distinctUsers.length, + target: distinctUsers.length * 2, // Target is user-defined + max: developerCount + }; + + this.logCalculation( + 'MONTHLY DEVS REPORTING TIME SAVINGS', + { + monthlySurveysCount: this.surveysMonthly.length, + distinctUsersCount: distinctUsers.length, + developerCount: this.settings.developerCount, + timeRange: '30 days' + }, + 'Count distinct userIds from monthly surveys (last 30 days), set current = distinctUsers.length, target = current * 2, max = developerCount', + result + ); + + return result; + } + + /** + * Calculate percentage of seats reporting time savings + */ + calculatePercentOfSeatsReportingTimeSavings(): Target { + let seats = this.calculateSeats().current; + let monthlyReporting = this.calculateMonthlyDevsReportingTimeSavings().current; + const currentPercentage = this.calculatePercentage(monthlyReporting, seats); + + seats = this.calculateSeats().target; + monthlyReporting = this.calculateMonthlyDevsReportingTimeSavings().target; + const targetPercentage = this.calculatePercentage(monthlyReporting, seats); + + const result = { + current: currentPercentage, + target: targetPercentage, // Target can be user-defined + max: 100 + }; + + this.logCalculation( + 'PERCENTAGE OF SEATS REPORTING TIME SAVINGS', + { + monthlyReportingCount: monthlyReporting, + seatsCount: seats + }, + 'Calculate (monthlyReporting / seats) * 100, set current = percentage, max = 100', + result + ); + + return result; + } + + /** + * Calculate percentage of seats adopted + */ + calculatePercentOfSeatsAdopted(): Target { + let seats = this.calculateSeats().current; + let adoptedDevs = this.calculateAdoptedDevs().current; + const currentPercentage = this.calculatePercentage(adoptedDevs, seats); + + seats = this.calculateSeats().target; + adoptedDevs = this.calculateAdoptedDevs().target; + const targetPercentage = this.calculatePercentage(adoptedDevs, seats); + + const result = { + current: currentPercentage, + target: targetPercentage, // Target is user-defined + max: 100 + }; + + this.logCalculation( + 'PERCENTAGE OF SEATS ADOPTED', + { + adoptedDevsCount: adoptedDevs, + seatsCount: seats + }, + 'Calculate (adoptedDevs / seats) * 100, set current = percentage, max = 100', + result + ); + + return result; + } + + /** + * Calculate percentage of max possible seats adopted + */ + calculatePercentOfMaxAdopted(): Target { + // Convert maxSeats to number to ensure correct type + const maxSeats = typeof this.settings.developerCount === 'string' + ? parseInt(this.settings.developerCount, 10) + : (this.settings.developerCount || 0); + + let adoptedDevs = this.calculateAdoptedDevs().current; + const currentPercentage = this.calculatePercentage(adoptedDevs, maxSeats); + + adoptedDevs = this.calculateAdoptedDevs().target; + const targetPercentage = this.calculatePercentage(adoptedDevs, maxSeats); + + const result = { + current: currentPercentage, + target: targetPercentage, + max: 100 + }; + + this.logCalculation( + 'PERCENTAGE OF MAX ADOPTED', + { + adoptedDevsCount: adoptedDevs, + developerCount: maxSeats + }, + 'Calculate (adoptedDevs / developerCount) * 100, set current = currentPercentage, max = 100', + result + ); + + return result; + } + + // === USER-LEVEL CALCULATIONS === + + /** + * Calculate daily suggestions, accepts, chats, etc. per developer + */ + calculateDailyMetrics(): { + suggestions: Target; + chats: Target; + acceptances: Target; + dotcomChats: Target; + prSummaries: Target; + } { + // Extract metrics from the 5 most recent days in the array + let suggestionsPerDev = 0; + let chatsPerDev = 0; + let dotcomChatsPerDev = 0; + let prSummariesPerDev = 0; + const acceptanceRate = 0.7; // Placeholder assumption for acceptance rate + + const metricsWeekly = this.metricsWeekly + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 5); + + // --- NEW DEBUG LOG ---------------------------------------------------- + this.logCalculation( + 'METRICS WEEKLY DATA', + { + // capture only the most relevant fields for quick inspection + metricsWeekly: metricsWeekly.map(m => ({ + date: m.date, + total_active_users: m.total_active_users, + total_engaged_users: m.total_engaged_users, + total_code_suggestions: m.copilot_ide_code_completions?.total_code_suggestions ?? 0, + total_chats: m.copilot_ide_chat?.total_chats ?? 0, + dotcom_total_chats: m.copilot_dotcom_chat?.total_chats ?? 0, + pr_summaries_created: m.copilot_dotcom_pull_requests?.total_pr_summaries_created ?? 0 + })) + }, + 'Sort metrics by date descending and take the 5 most recent entries.', + metricsWeekly.length // result: number of entries logged + ); + // ---------------------------------------------------------------------- + + // Count how many metrics entries had valid data + let validSuggestionsCount = 0; + let validChatsCount = 0; + let validDotcomChatsCount = 0; + let validPrSummariesCount = 0; + + // Calculate average directly into per-developer metrics + metricsWeekly.forEach(curr => { + // Process code suggestions data + if (curr.copilot_ide_code_completions && + curr.copilot_ide_code_completions.total_code_suggestions > 0 && + curr.copilot_ide_code_completions.total_engaged_users > 0) { + suggestionsPerDev += + curr.copilot_ide_code_completions.total_code_suggestions / + curr.copilot_ide_code_completions.total_engaged_users; + validSuggestionsCount++; + } + + // Process IDE chat data + const ideChat = curr.copilot_ide_chat; + if (ideChat && ideChat.total_chats > 0 && curr.total_active_users > 0) { + chatsPerDev += ideChat.total_chats / curr.total_active_users; + validChatsCount++; + } + + // Process dot-com chat data + const dotcomChat = curr.copilot_dotcom_chat; + if (dotcomChat && dotcomChat.total_chats > 0 && curr.total_active_users > 0) { + dotcomChatsPerDev += dotcomChat.total_chats / curr.total_active_users; + validDotcomChatsCount++; + } + + // Process PR summaries data + const prSummaries = curr.copilot_dotcom_pull_requests; + if (prSummaries && prSummaries.total_pr_summaries_created > 0 && curr.total_active_users > 0) { + prSummariesPerDev += prSummaries.total_pr_summaries_created / curr.total_active_users; + validPrSummariesCount++; + } + }); + + // Calculate the averages by dividing by the number of valid days found + if (validSuggestionsCount > 0) { + suggestionsPerDev /= validSuggestionsCount; + } + + if (validChatsCount > 0) { + chatsPerDev /= validChatsCount; + } + + if (validDotcomChatsCount > 0) { + dotcomChatsPerDev /= validDotcomChatsCount; + } else { + // Fallback to ratio estimation if no direct data + dotcomChatsPerDev = chatsPerDev * 0.33; // Placeholder assumption + } + + if (validPrSummariesCount > 0) { + prSummariesPerDev /= validPrSummariesCount; + } + + // Calculate acceptance rate (placeholder until real data) + const acceptancesPerDev = suggestionsPerDev * acceptanceRate; + + const timestamp = metricsWeekly.length > 0 ? new Date(metricsWeekly[0].date).toISOString() : 'unknown'; + + // Create results for each metric + const suggestionsResult = { + current: this.roundToDecimal(suggestionsPerDev), + target: 100, + max: 150 // Based on frontend hardcoded value + }; + + const chatsResult = { + current: this.roundToDecimal(chatsPerDev), + target: 30, + max: 60 // Based on frontend hardcoded value + }; + + const acceptancesResult = { + current: this.roundToDecimal(acceptancesPerDev), + target: 35, + max: 100 + }; + + const dotcomChatsResult = { + current: this.roundToDecimal(dotcomChatsPerDev), + target: this.roundToDecimal(dotcomChatsPerDev) * 1.25, // Target is 25% higher + max: 100 + }; + + const prSummariesResult = { + current: this.roundToDecimal(prSummariesPerDev), + target: this.roundToDecimal(prSummariesPerDev * 1.5), // Target is 50% higher + max: 5 // Based on frontend hardcoded value + }; + + // Log the calculations + this.logCalculation( + 'DAILY SUGGESTIONS PER DEVELOPER', + { + validMetricsCount: validSuggestionsCount, + metricsDataPoints: metricsWeekly.length, + avgSuggestionsPerDay: suggestionsPerDev, + timestamp: timestamp + }, + 'Sort metrics by date, take 5 most recent, calculate average daily suggestions per developer', + suggestionsResult + ); + + this.logCalculation( + 'DAILY CHAT TURNS PER DEVELOPER', + { + validMetricsCount: validChatsCount, + metricsDataPoints: metricsWeekly.length, + avgChatsPerDay: chatsPerDev, + timestamp: timestamp + }, + 'Calculate average totalChats / activeUsers from 5 most recent days', + chatsResult + ); + + this.logCalculation( + 'DAILY ACCEPTANCES PER DEVELOPER', + { + dailySuggestions: suggestionsPerDev, + assumedAcceptanceRate: acceptanceRate, + timestamp: timestamp + }, + 'Calculate dailySuggestions * assumedAcceptanceRate (placeholder until actual data available)', + acceptancesResult + ); + + this.logCalculation( + 'DAILY DOTCOM CHATS PER DEVELOPER', + { + validMetricsCount: validDotcomChatsCount, + hasDirectData: validDotcomChatsCount > 0, + fallbackChatsPerDev: validDotcomChatsCount === 0 ? chatsPerDev : null, + assumedDotComRatio: validDotcomChatsCount === 0 ? 0.33 : null, + timestamp: timestamp + }, + validDotcomChatsCount > 0 ? + 'Calculate average dotcomChats / activeUsers from 5 most recent days' : + 'Calculate chatsPerDev * assumedDotComRatio (using fallback ratio)', + dotcomChatsResult + ); + + this.logCalculation( + 'WEEKLY PR SUMMARIES PER DEVELOPER', + { + validMetricsCount: validPrSummariesCount, + metricsDataPoints: metricsWeekly.length, + avgPrSummariesPerDev: prSummariesPerDev, + timestamp: timestamp + }, + 'Calculate average prSummaries / activeUsers from 5 most recent days', + prSummariesResult + ); + + return { + suggestions: suggestionsResult, + chats: chatsResult, + acceptances: acceptancesResult, + dotcomChats: dotcomChatsResult, + prSummaries: prSummariesResult + }; + } + + /** + * Calculate daily suggestions per developer + */ + calculateDailySuggestions(): Target { + return this.calculateDailyMetrics().suggestions; + } + + /** + * Calculate daily chat turns per developer + */ + calculateDailyChatTurns(): Target { + return this.calculateDailyMetrics().chats; + } + + /** + * Calculate daily acceptances + */ + calculateDailyAcceptances(): Target { + return this.calculateDailyMetrics().acceptances; + } + + /** + * Calculate daily dot com chats + */ + calculateDailyDotComChats(): Target { + return this.calculateDailyMetrics().dotcomChats; + } + + /** + * Calculate weekly PR summaries per developer + */ + calculateWeeklyPRSummaries(): Target { + return this.calculateDailyMetrics().prSummaries; + } + + /** + * Calculate weekly time saved in hours per developer + */ + calculateWeeklyTimeSavedHrs(): Target { + // If no surveys, return default values + if (this.surveysWeekly.length === 0) { + return { current: 0, target: 0, 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 }; + } + + // Group surveys by user to get average time saved per user + 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; + return sum + percentTimeSaved; + }, 0); + return totalPercent / userSurveys.length; // Average percent time saved per user + }); + + // 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; + + // 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); + + // Calculate max based on settings + const maxPercentTimeSaved = typeof this.settings.percentTimeSaved === 'number' ? this.settings.percentTimeSaved : 20; + const maxWeeklyTimeSaved = weeklyDevHours * (maxPercentTimeSaved / 100); + + 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 + max: this.roundToDecimal(maxWeeklyTimeSaved || 10) // Provide a fallback + }; + + this.logCalculation( + 'WEEKLY TIME SAVED HRS PER DEVELOPER', + { + distinctUsersCount: distinctUsers.length, + surveysCount: this.surveysWeekly.length, + avgPercentTimeSaved: avgPercentTimeSaved, + userPercentages: userTimeSavings, + hoursPerYear: hoursPerYear, + percentCoding: percentCoding, + weeklyDevHours: weeklyDevHours + }, + 'Calculate average time saved percentage per user, then weeklyDevHours * (avgPercentTimeSaved / 100)', + result + ); + + return result; + } + + // === IMPACT-LEVEL CALCULATIONS === + + /** + * Calculate monthly time savings in hours + */ + calculateMonthlyTimeSavingsHrs(): Target { + const adoptedDevs = this.calculateAdoptedDevs().current; + const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; + const monthlyTimeSavings = adoptedDevs * weeklyTimeSavedHrs * 4; // Assuming 4 weeks per month + + const result = { + current: this.roundToDecimal(monthlyTimeSavings), + target: 0, // Target is user-defined + max: this.roundToDecimal(80 * this.calculateSeats().current) // Based on target.service.ts + }; + + this.logCalculation( + 'MONTHLY TIME SAVINGS HRS', + { + adoptedDevsCount: adoptedDevs, + weeklyTimeSavedHrs: weeklyTimeSavedHrs, + seatsCount: this.calculateSeats().current + }, + 'Calculate adoptedDevs * weeklyTimeSavedHrs * 4, set current = monthlyTimeSavings, max = 80 * seats', + result + ); + + return result; + } + + /** + * Calculate annual time savings in dollars + */ + calculateAnnualTimeSavingsAsDollars(): Target { + 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; + 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 hourlyRate = devCostPerYear > 0 ? (devCostPerYear / hoursPerYear) : 50; + + const annualSavings = weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs; + + // For dollar values, we can use 0 decimals (whole dollars) + 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 + }; + + this.logCalculation( + 'ANNUAL TIME SAVINGS AS DOLLARS', + { + adoptedDevsCount: adoptedDevs, + weeklyTimeSavedHrs: weeklyTimeSavedHrs, + weeksInYear: weeksInYear, + hourlyRate: hourlyRate, + seatsCount: this.calculateSeats().current + }, + 'Calculate weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs, set current = annualSavings, max = 80 * seats * 50', + result + ); + + return result; + } + + /** + * Calculate productivity or throughput boost percentage + */ + calculateProductivityOrThroughputBoostPercent(): Target { + 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; + const hoursPerWeek = hoursPerYear / 50 || 40; // Default to 40 if undefined + + // Calculate productivity boost factor (not percentage) + const productivityBoost = (hoursPerWeek + weeklyTimeSavedHrs) / hoursPerWeek; + + // Convert to percentage increase (e.g., 1.2 becomes 20%) + const productivityBoostPercent = (productivityBoost - 1) * 100; + + const result = { + current: this.roundToDecimal(productivityBoostPercent), + target: this.roundToDecimal(Math.min(productivityBoostPercent * 1.5, 20)), // Target is 50% higher, capped at 20% + max: 25 // Based on target.service.ts + }; + + this.logCalculation( + 'PRODUCTIVITY OR THROUGHPUT BOOST PERCENT', + { + 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', + result + ); + + return result; + } + + /** + * Calculate all targets based on fetched data + */ + calculateAllTargets(): Targets { + if (!this.dataFetched) { + throw new Error('Data must be fetched before calculations can be performed'); + } + + const result = { + org: { + seats: this.calculateSeats(), + adoptedDevs: this.calculateAdoptedDevs(), + monthlyDevsReportingTimeSavings: this.calculateMonthlyDevsReportingTimeSavings(), + percentOfSeatsReportingTimeSavings: this.calculatePercentOfSeatsReportingTimeSavings(), + percentOfSeatsAdopted: this.calculatePercentOfSeatsAdopted(), + percentOfMaxAdopted: this.calculatePercentOfMaxAdopted(), + }, + user: { + dailySuggestions: this.calculateDailySuggestions(), + dailyAcceptances: this.calculateDailyAcceptances(), + dailyChatTurns: this.calculateDailyChatTurns(), + dailyDotComChats: this.calculateDailyDotComChats(), + weeklyPRSummaries: this.calculateWeeklyPRSummaries(), + weeklyTimeSavedHrs: this.calculateWeeklyTimeSavedHrs(), + }, + impact: { + monthlyTimeSavingsHrs: this.calculateMonthlyTimeSavingsHrs(), + annualTimeSavingsAsDollars: this.calculateAnnualTimeSavingsAsDollars(), + productivityOrThroughputBoostPercent: this.calculateProductivityOrThroughputBoostPercent(), + } + }; + + // Sanitize the result to ensure no null values + return this.sanitizeTargets(result); + } + + /** + * Ensure no null values in the targets object + */ + private sanitizeTargets(targets: Targets): Targets { + // Helper function to sanitize a single Target object + const sanitizeTarget = (target: Target): Target => { + return { + current: target.current === null ? 0 : target.current, + target: target.target === null ? 0 : target.target, + max: target.max === null ? 0 : target.max + }; + }; + + // Process each section of the targets object + ['org', 'user', 'impact'].forEach(section => { + Object.keys(targets[section]).forEach(key => { + targets[section][key] = sanitizeTarget(targets[section][key]); + }); + }); + + return targets; + } + + /** + * One-step method to fetch data and perform all calculations + * The instance method - used when you already have a service instance + */ + async fetchAndCalculateTargets( + org: string | null, + enableLogging: boolean = false, + includeLogsInResponse: boolean = false + ): Promise<{ targets: Targets; logs?: CalcLogType[] }> { + this.debugLogging = enableLogging; + this.resetLogging(); // Reset logging state + logger.info(`Calculation logging ${enableLogging ? 'enabled' : 'disabled'}`); + + await this.fetchCalculationData(org); + const targets = this.calculateAllTargets(); + + // Return both targets and logs if requested + if (includeLogsInResponse && this.debugLogging) { + return { + targets, + logs: this.calculationLogs + }; + } + + return { targets }; + } + + /** + * Static method to create an instance and calculate targets in one step + * Used for convenience when you don't want to create an instance first + * This is a facade that creates an instance and calls the instance method + */ + static async fetchAndCalculateTargets( + org: string | null, + enableLogging: boolean = false, + includeLogsInResponse: boolean = false + ): Promise<{ targets: Targets; logs?: CalcLogType[] }> { + logger.info('Static method received params:', { + org: org || 'null', + enableLogging, + includeLogsInResponse + }); + + const service = new TargetCalculationService(); + + // Explicitly pass all parameters to ensure they're not overwritten + const result = await service.fetchAndCalculateTargets( + org, + enableLogging, + includeLogsInResponse + ); + + // Verify the structure of the result before returning + const hasLogs = Boolean(result.logs && result.logs.length > 0); + logger.info(`Result has logs: ${hasLogs}, includeLogsInResponse: ${includeLogsInResponse}`); + + return result; + } +} + +// Allow isolated testing +//to execute this module directly, use the command: +// node --loader ts-node/esm backend/src/services/target-calculation-service.ts +if (import.meta.url.endsWith(process.argv[1])) { + (async () => { + // Example of using the static method with logs included in response + const result = await TargetCalculationService.fetchAndCalculateTargets('test-org', true, true); + logger.info('Calculated Targets:', JSON.stringify(result.targets, null, 2)); + logger.info(`Returned ${result.logs?.length || 0} calculation logs`); + })(); +} \ No newline at end of file diff --git a/backend/src/services/target.service.ts b/backend/src/services/target.service.ts index 4db20640..99d8943b 100644 --- a/backend/src/services/target.service.ts +++ b/backend/src/services/target.service.ts @@ -1,7 +1,8 @@ import mongoose from 'mongoose'; -import adoptionService, { AdoptionType } from './adoption.service.js'; -import app from '../index.js'; +import { AdoptionType } from './adoption.service.js' import { SettingsType } from './settings.service.js'; +import { TargetCalculationService } from './target-calculation-service.js'; +import logger from './logger.js'; interface Target { current: number; @@ -54,7 +55,12 @@ class TargetValuesService { } } - calculateTargets(settings: SettingsType, adoptions: AdoptionType[]): Targets { + //TODO: remove this method + // This method is not used in the current codebase and should be removed + // It was originally intended to calculate targets based on settings and adoptions + // but is now replaced by the fetchAndCalculateTargets method in TargetCalculationService + // and should be removed to avoid confusion. + calculateTargets_ori(settings: SettingsType, adoptions: AdoptionType[]): Targets { const topAdoptions = adoptions .sort((a, b) => b.totalActive - a.totalActive) .slice(0, 10); @@ -98,20 +104,21 @@ class TargetValuesService { }, }; } + //TODO: remove the unused parameters from this method + calculateTargets(): Promise<{ targets: Targets; logs?: unknown[] }> { + return TargetCalculationService.fetchAndCalculateTargets(null, true, false); //always true for enableLogging for now to audit calculations, always false for includeLogsInResponse. + } - async initialize() { + //create default targets if they don't exist + async initialize() { try { const Targets = mongoose.model('Targets'); const existingTargets = await Targets.findOne(); - if (!existingTargets) { - const settings = await app.settingsService.getAllSettings(); - const adoptions = await adoptionService.getAllAdoptions2({ - filter: { enterprise: 'enterprise' }, - projection: {} - }); - const initialData = this.calculateTargets(settings, adoptions); - await Targets.create(initialData); + if (!existingTargets ) { + const result = await this.calculateTargets(); + await Targets.create(result.targets); + logger.info('Default targets created successfully.'); } } catch (error) { throw new Error(`Error initializing target values: ${error}`); diff --git a/backend/src/services/teams.service.js b/backend/src/services/teams.service.js new file mode 100644 index 00000000..21be8f28 --- /dev/null +++ b/backend/src/services/teams.service.js @@ -0,0 +1,553 @@ +import { Endpoints } from '@octokit/types'; +import { SeatType } from "../models/seats.model.js"; +import { components } from "@octokit/openapi-types"; +import mongoose from 'mongoose'; +import { MemberActivityType, MemberType } from 'models/teams.model.js'; +import fs from 'fs'; +import adoptionService from './adoption.service.js'; +import logger from './logger.js'; + +type _Seat = NonNullable[0]; +export interface SeatEntry extends _Seat { + plan_type: "business" | "enterprise" | "unknown"; + assignee: components['schemas']['simple-user']; +} + +type MemberDailyActivity = { + [date: string]: { + totalSeats: number, + totalActive: number, + totalInactive: number, + active: { + [assignee: string]: SeatType + }, + inactive: { + [assignee: string]: SeatType + } + }; +}; + +class SeatsService { + async getAllSeats(org?: string) { + const Member = mongoose.model('Member'); + + const seats = await Member.find({ + ...(org ? { org } : {}) + }) + .select('org login id name url avatar_url') + .populate({ + path: 'seat', + select: '-_id -__v', + options: { lean: true } + }) + .sort({ 'seat.last_activity_at': -1 }) + .exec(); + + return seats; + } + + async getAssignee(id: number) { + const Seats = mongoose.model('Seats'); + const Member = mongoose.model('Member'); + const member = await Member.findOne({ id }).sort({ org: -1 }); //this temporarily resolves a bug where one org fails but the other one succeeds + + if (!member) { + throw `Member with id ${id} not found` + } + + return Seats.find({ + assignee: member._id + }) + .lean() + .populate({ + path: 'assignee', // Link to Member model 👤 + model: Member, + select: 'login id avatar_url -_id' // Only select needed fields 🎯 + }); + } + + async getAssigneeByLogin(login: string) { + const Seats = mongoose.model('Seats'); + const Member = mongoose.model('Member'); + const member = await Member.findOne({ login }); + + if (!member) { + throw `Member with id ${login} not found` + } + + return Seats.find({ + assignee: member._id + }) + .lean() + .populate({ + path: 'assignee', // Link to Member model 👤 + model: Member, + select: 'login id avatar_url -_id' // Only select needed fields 🎯 + }); + } + + async insertSeats(org: string, queryAt: Date, data: SeatEntry[], team?: string) { + const Members = mongoose.model('Member'); + const Seats = mongoose.model('Seats'); + const ActivityTotals = mongoose.model('ActivityTotals'); + + // fill the data to 10,000 entries for testing + // data = new Array(10000).fill(0).map((entry, index) => { + // const seat = data[index % data.length]; + // return { + // ...seat, + // plan_type: seat.plan_type || 'unknown', + // assignee: { + // ...seat.assignee, + // id: seat.assignee.id + index, + // login: seat.assignee.login || + // `test-login-${index}`, + // node_id: seat.assignee.node_id || `test-node-id-${index}`, + // avatar_url: seat.assignee.avatar_url || + // `https://avatars.githubusercontent.com/u/${index}?v=4`, + // gravatar_id: seat.assignee.gravatar_id || `test-gravatar-id-${index}`, + // url: seat.assignee.url || `https://api.github.com/users/test-login-${index}`, + // html_url: seat.assignee.html_url || ``, + // followers_url: seat.assignee.followers_url || `https://api.github.com/users/test-login-${index}/followers`, + // following_url: seat.assignee.following_url || `https://api.github.com/users/test-login-${index}/following{/other_user}`, + // gists_url: seat.assignee.gists_url || `https://api.github.com/users/test-login-${index}/gists{/gist_id}`, + // starred_url: seat.assignee.starred_url || `https://api.github.com/users/test-login-${index}/starred{/owner}{/repo}`, + // subscriptions_url: seat.assignee.subscriptions_url || `https://api.github.com/users/test-login-${index}/subscriptions`, + // organizations_url: seat.assignee.organizations_url || `https://api.github.com/users/test-login-${index}/orgs`, + // repos_url: seat.assignee.repos_url || `https://api.github.com/users/test-login-${index}/repos`, + // events_url: seat.assignee.events_url || `https://api.github.com/users/test-login-${index}/events{/privacy}`, + // received_events_url: seat.assignee.received_events_url || `https://api.github.com/users/test-login-${index}/received_events`, + // type: seat.assignee.type || `User`, + // site_admin: seat.assignee.site_admin || false + // } + // } + // }); + + logger.info(`Inserting ${data.length} seat assignments for ${org}`); + + const memberUpdates = data.map(seat => ({ + updateOne: { + filter: { org, id: seat.assignee.id }, + update: { + $set: { + ...team ? { team } : undefined, + org, + id: seat.assignee.id, + login: seat.assignee.login, + node_id: seat.assignee.node_id, + avatar_url: seat.assignee.avatar_url, + gravatar_id: seat.assignee.gravatar_id || '', + url: seat.assignee.url, + html_url: seat.assignee.html_url, + followers_url: seat.assignee.followers_url, + following_url: seat.assignee.following_url, + gists_url: seat.assignee.gists_url, + starred_url: seat.assignee.starred_url, + subscriptions_url: seat.assignee.subscriptions_url, + organizations_url: seat.assignee.organizations_url, + repos_url: seat.assignee.repos_url, + events_url: seat.assignee.events_url, + received_events_url: seat.assignee.received_events_url, + type: seat.assignee.type, + site_admin: seat.assignee.site_admin, + } + }, + upsert: true, + } + })); + + logger.debug(`Writing ${memberUpdates.length} members`); + await Members.bulkWrite(memberUpdates); + + const updatedMembers = await Members.find({ + org, + id: { $in: data.map(seat => seat.assignee.id) } + }); + + const seatsData = data.map((seat) => ({ + queryAt, + org, + team, + ...seat, + assignee_id: seat.assignee.id, + assignee_login: seat.assignee.login, + assignee: updatedMembers.find(m => m.id === seat.assignee.id)?._id + })); + logger.debug(`Writing ${seatsData.length} seats`); + + const seatInsertOperations = seatsData.map(seat => ({ + insertOne: { + document: seat + } + })); + const bulkWriteResult = await Seats.bulkWrite(seatInsertOperations, { ordered: false }); + logger.debug(`Inserted ${bulkWriteResult.insertedCount} seats`); + const seatResults = await Seats.find({ + queryAt, + org, + assignee_id: { $in: data.map(seat => seat.assignee.id) } + }).sort({ createdAt: -1 }).limit(seatsData.length); + + const memberSeatUpdates = seatResults.map(seat => ({ + updateOne: { + filter: { org, id: seat.assignee_id }, + update: { + $set: { seat: seat._id } + } + } + })); + logger.debug(`Writing ${memberSeatUpdates.length} member seat updates`); + await Members.bulkWrite(memberSeatUpdates); + + const adoptionData = { + enterprise: null, + org: org, + team: null, + date: queryAt, + ...adoptionService.calculateAdoptionTotals(queryAt, data), + seats: seatResults.map(seat => ({ + login: seat.assignee_login, + last_activity_at: seat.last_activity_at, + last_activity_editor: seat.last_activity_editor, + _assignee: seat.assignee, + _seat: seat._id, + })) + } + logger.debug(`Writing ${adoptionData.seats.length} adoption data`); + await adoptionService.createAdoption(adoptionData); + + const today = new Date(queryAt); + // add 1 to day + // today.setDate(today.getDate() + 1); + today.setUTCHours(0, 0, 0, 0); + const activityUpdates = seatResults.map(seat => ({ + updateOne: { + filter: { + org, + assignee: seat.assignee, + assignee_id: seat.assignee_id, + assignee_login: seat.assignee_login, + date: today + }, + update: [{ + $set: { + total_active_time_ms: { + $cond: { + if: { $eq: [seat.last_activity_at, null] }, + then: { $ifNull: ["$total_active_time_ms", 0] }, + else: { + $add: [ + { $ifNull: ["$total_active_time_ms", 0] }, + { + $cond: { + if: { + $and: [ + { + $or: [ + { $eq: ["$last_activity_at", null] }, + { $lt: ["$last_activity_at", seat.last_activity_at] } + ] + }, + { $gt: [seat.last_activity_at, today] } + ] + }, + then: 1, + else: 0 + } + } + ] + } + } + } + } + }, { + $set: { + last_activity_editor: seat.last_activity_editor, + last_activity_at: seat.last_activity_at + } + }], + upsert: true + } + })).filter(update => update !== null); + + if (activityUpdates.length > 0) { + logger.debug(`Writing ${activityUpdates.length} activity updates`); + await ActivityTotals.bulkWrite(activityUpdates); + } + + return { + seats: seatResults, + members: updatedMembers, + adoption: adoptionData + } + } + + async getMembersActivity(params: { + org?: string; + daysInactive?: number; + precision?: 'hour' | 'day' | 'minute'; + since?: string; + until?: string; + } = {}): Promise { + const Seats = mongoose.model('Seats'); + // const seats = await Seats.find({}) + // return seats.length; + + // return; + // const Member = mongoose.model('Member'); + const { org, daysInactive = 30, precision = 'day', since, until } = params; + const assignees: MemberActivityType[] = await Seats.aggregate([ + { + $match: { + ...(org && { org }), + ...(since && { createdAt: { $gte: new Date(since) } }), + ...(until && { createdAt: { $lte: new Date(until) } }), + last_activity_at: { $ne: null } // Only get records with activity + } + }, + { + $lookup: { + from: 'members', + localField: 'assignee', + foreignField: '_id', + as: 'memberDetails' + } + }, + { + $unwind: '$memberDetails' + }, + { + $group: { + _id: '$memberDetails._id', + login: { $first: '$memberDetails.login' }, + id: { $first: '$memberDetails.id' }, + activity: { + $push: { + last_activity_at: '$last_activity_at', + createdAt: '$createdAt', + last_activity_editor: '$last_activity_editor' + } + } + } + } + ]) + // .hint({ org: 1, createdAt: 1 }) + // .allowDiskUse(true) + // .explain('executionStats'); + + const activityDays: MemberDailyActivity = {}; + assignees.forEach((assignee) => { + if (!assignee.activity) return; + assignee.activity.forEach((activity) => { + const fromTime = activity.last_activity_at?.getTime() || 0; + const toTime = activity.createdAt.getTime(); + const diff = Math.floor((toTime - fromTime) / 86400000); + const dateIndex = new Date(activity.createdAt); + if (precision === 'day') { + dateIndex.setUTCHours(0, 0, 0, 0); + } else if (precision === 'hour') { + dateIndex.setUTCMinutes(0, 0, 0); + } + const dateIndexStr = new Date(dateIndex).toISOString(); + if (!activityDays[dateIndexStr]) { + activityDays[dateIndexStr] = { + totalSeats: 0, + totalActive: 0, + totalInactive: 0, + active: {}, + inactive: {} + } + } + if (activityDays[dateIndexStr].active[assignee.login] || activityDays[dateIndexStr].inactive[assignee.login]) { + return; // already processed for this day + } + if (diff > daysInactive) { + activityDays[dateIndexStr].inactive[assignee.login] = assignee.activity[0]; + } else { + activityDays[dateIndexStr].active[assignee.login] = assignee.activity[0]; + } + }); + }); + Object.entries(activityDays).forEach(([date, activity]) => { + activityDays[date].totalSeats = Object.values(activity.active).length + Object.values(activity.inactive).length + activityDays[date].totalActive = Object.values(activity.active).length + activityDays[date].totalInactive = Object.values(activity.inactive).length + }); + + const sortedActivityDays = Object.fromEntries( + Object.entries(activityDays) + .sort(([dateA], [dateB]) => new Date(dateA).getTime() - new Date(dateB).getTime()) + ); + + fs.writeFileSync('sortedActivityDays.json', JSON.stringify(sortedActivityDays, null, 2), 'utf-8'); + + return sortedActivityDays; + } + + async getMembersActivityTotals(params: { + org?: string; + since?: string; + until?: string; + }) { + const { org, since, until } = params; + const Member = mongoose.model('Member'); + + const match: mongoose.FilterQuery = {}; + if (org) match.org = org; + if (since || until) { + match.createdAt = { + ...(since && { $gte: new Date(since) }), + ...(until && { $lte: new Date(until) }) + }; + } + + const assignees: MemberType[] = await Member + .aggregate([ + { $match: match }, + { + $lookup: { + from: 'seats', // MongoDB collection name (lowercase) + localField: '_id', // Member model field + foreignField: 'assignee', // Seats model field + as: 'activity' // Name for the array of seats + } + } + ]); + + const activityTotals = assignees.reduce((totals, assignee) => { + if (assignee.activity) { + totals[assignee.login] = assignee.activity.reduce((totalMs, activity, index) => { + if (index === 0) return totalMs; + if (!activity.last_activity_at) return totalMs; + const prev = assignee.activity?.[index - 1]; + const diff = activity.last_activity_at?.getTime() - (prev?.last_activity_at?.getTime() || 0); + if (diff) { + if (diff > 1000 * 60 * 30) { + totalMs += 1000 * 60 * 30; + } else { + totalMs += diff; + } + } + return totalMs; + }, 0); + } + return totals; + }, {} as { [assignee: string]: number }); + + return Object.entries(activityTotals).sort((a: [string, number], b: [string, number]) => b[1] - a[1]); + } + + async getMembersActivityTotals2(params: { + org?: string; + since?: string; + until?: string; + limit?: number; + }) { + const ActivityTotals = mongoose.model('ActivityTotals'); + const { org, since, until } = params; + const limit = typeof params.limit === 'string' ? parseInt(params.limit) : (params.limit || 100); + + const match: mongoose.FilterQuery = {}; + if (org) match.org = org; + if (since || until) { + match.date = { + ...(since && { $gte: new Date(since) }), + ...(until && { $lte: new Date(until) }) + }; + } + + const totals = await ActivityTotals.aggregate([ + { $match: match }, + { + $group: { + _id: { + date: "$date", + login: "$assignee_login" + }, + daily_time: { $sum: "$total_active_time_ms" }, + last_activity_at: { $max: "$last_activity_at" }, + last_activity_editor: { $last: "$last_activity_editor" }, + assignee_id: { $first: "$assignee_id" } + } + }, + { + $lookup: { + from: 'members', + localField: '_id.login', + foreignField: 'login', + as: 'memberDetails' + } + }, + { + $unwind: { + path: '$memberDetails', + preserveNullAndEmptyArrays: true + } + }, + { + $group: { + _id: "$_id.login", + total_time: { $sum: "$daily_time" }, + last_activity_at: { $max: "$last_activity_at" }, + last_activity_editor: { $last: "$last_activity_editor" }, + assignee_id: { $first: "$assignee_id" }, + avatar_url: { $first: "$memberDetails.avatar_url" }, + name: { $first: "$memberDetails.name" }, + url: { $first: "$memberDetails.url" }, + html_url: { $first: "$memberDetails.html_url" }, + team: { $first: "$memberDetails.team" }, + org: { $first: "$memberDetails.org" }, + type: { $first: "$memberDetails.type" } + } + }, + { $sort: { total_time: -1 } }, + { $limit: limit }, + { + $project: { + _id: 0, + login: '$_id', + total_time: 1, + last_activity_at: 1, + last_activity_editor: 1, + assignee_id: 1, + avatar_url: 1, + name: 1, + url: 1, + html_url: 1, + team: 1, + org: 1, + type: 1 + } + } + ]); + + return totals; + } + + async searchMembersByLogin(query) { + try { + if (!query) return []; + + // Using MongoDB's $regex for partial text matching (case-insensitive) + const Member = mongoose.model('Member'); + const members = await Member.find({ + login: { $regex: query, $options: 'i' } + }) + .select('login id avatar_url name org') + .limit(10) + .lean(); + + console.log(`Found ${members.length} members matching query: ${query}`); + return members; + } catch (error) { + console.error('Error searching members by login:', error); + throw error; + } + } +} + +export default new SeatsService(); + +export { + MemberDailyActivity +} \ No newline at end of file diff --git a/backend/src/services/teams.service.ts b/backend/src/services/teams.service.ts index 3acca5c2..cd0da379 100644 --- a/backend/src/services/teams.service.ts +++ b/backend/src/services/teams.service.ts @@ -1,6 +1,7 @@ import { Endpoints } from "@octokit/types"; import mongoose from "mongoose"; import logger from "./logger.js"; +import { MemberType } from "models/teams.model.js"; class TeamsService { async updateTeams( @@ -27,12 +28,18 @@ class TeamsService { if (parentTeam) { await Team.findOneAndUpdate( { githubId: team.id }, - { parent: parentTeam._id } + { parent: parentTeam._id }, // Use MongoDB _id for the parent ); } } } + // const updated = await Team.findOneAndUpdate( + // { organization: org }, + // { parent: teams[0] }, + // { upsert: true } + // ); + await Team.findOneAndUpdate( { githubId: -1 }, { @@ -131,14 +138,14 @@ class TeamsService { return team?.updatedAt || new Date(0); } - async getMemberByLogin(login: string) { + async getMemberByLogin(login: string): Promise { const Member = mongoose.model("Member"); return await Member.findOne({ login }) .select("login name url avatar_url") .exec(); } - async getAllMembers(org?: string) { + async getAllMembers(org?: string): Promise { const Member = mongoose.model("Member"); try { return await Member.find({ @@ -181,6 +188,26 @@ class TeamsService { .sort({ name: "asc", "members.login": "asc" }) .exec(); } + + async searchMembersByLogin(query: string) { + try { + if (!query) return []; + + // Using MongoDB's $regex for partial text matching (case-insensitive) + const Member = mongoose.model('Member'); + const members = await Member.find({ + login: { $regex: query, $options: 'i' } + }) + .select('login id avatar_url name org') + .limit(10) + .lean(); + + return members; + } catch (error) { + console.error('Error searching members:', error); + throw error; + } + } } export default new TeamsService(); diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index c41b0035..21d5138d 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MaterialModule } from './material.module'; -import { RouterLink, RouterModule, RouterOutlet, Routes } from '@angular/router'; +import { RouterLink, RouterModule, RouterOutlet } from '@angular/router'; import { CommonModule } from '@angular/common'; import { TableComponent } from './shared/table/table.component'; diff --git a/frontend/src/app/error/error.component.ts b/frontend/src/app/error/error.component.ts index 4022445f..824036cd 100644 --- a/frontend/src/app/error/error.component.ts +++ b/frontend/src/app/error/error.component.ts @@ -22,11 +22,11 @@ export class ErrorComponent { private router: Router ) { const navigation = this.router.getCurrentNavigation(); - const state = navigation?.extras?.state as { error: any }; + const state = navigation?.extras?.state; if (state) { - if (state.error) { - this.error = state.error || JSON.stringify(state.error); + if (state['error']) { + this.error = state['error'] || JSON.stringify(state['error']); } else { this.error.message = 'An unknown error occurred'; } diff --git a/frontend/src/app/guards/setup.guard.ts b/frontend/src/app/guards/setup.guard.ts index 511b20e8..e49e661b 100644 --- a/frontend/src/app/guards/setup.guard.ts +++ b/frontend/src/app/guards/setup.guard.ts @@ -1,4 +1,4 @@ -import { Injectable, isDevMode } from '@angular/core'; +import { Injectable } from '@angular/core'; import { CanActivate, GuardResult, MaybeAsync, Router } from '@angular/router'; import { of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts index dffd7583..e85d195c 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts @@ -88,7 +88,6 @@ export class ActiveUsersChartComponent implements OnChanges { ) { } ngOnChanges() { - console.log('ngOnChanges', this.data); this._chartOptions = Object.assign({}, this.chartOptions, this._chartOptions); if (this._chartOptions?.series && this.data) { // Create an array with [total_time, login, avatar_url] for each point diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts index 210f4ef6..b3670d9b 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts @@ -5,7 +5,6 @@ import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { CopilotMetrics } from '../../../../../services/api/metrics.service.interfaces'; import { HighchartsService } from '../../../../../services/highcharts.service'; -import { LoadingSpinnerComponent } from '../../../../../shared/loading-spinner/loading-spinner.component'; export interface DashboardCardBarsInput { value: number; @@ -23,8 +22,7 @@ export interface DashboardCardBarsInput { MatIconModule, CommonModule, MatProgressBarModule, - MatIconModule, - // LoadingSpinnerComponent + MatIconModule ], templateUrl: './dashboard-card-bars.component.html', styleUrls: [ diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts index 5512dde7..29f20d1d 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts @@ -7,7 +7,6 @@ import { HighchartsChartModule } from 'highcharts-angular'; import { CommonModule } from '@angular/common'; import { HighchartsService } from '../../../../../services/highcharts.service'; import { CopilotMetrics } from '../../../../../services/api/metrics.service.interfaces'; -import { LoadingSpinnerComponent } from '../../../../../shared/loading-spinner/loading-spinner.component'; @Component({ selector: 'app-dashboard-card-drilldown-bar-chart', @@ -15,8 +14,7 @@ import { LoadingSpinnerComponent } from '../../../../../shared/loading-spinner/l imports: [ MatCardModule, CommonModule, - HighchartsChartModule, - // LoadingSpinnerComponent + HighchartsChartModule ], templateUrl: './dashboard-card-drilldown-bar-chart.component.html', styleUrls: [ diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-line-chart/dashboard-card-line-chart.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-line-chart/dashboard-card-line-chart.component.ts index 5700ebad..e248336e 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-line-chart/dashboard-card-line-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-line-chart/dashboard-card-line-chart.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HighchartsChartModule } from 'highcharts-angular'; import * as Highcharts from 'highcharts'; @@ -12,7 +12,7 @@ import { HighchartsService } from '../../../../../services/highcharts.service'; templateUrl: './dashboard-card-line-chart.component.html', styleUrls: ['./dashboard-card-line-chart.component.scss'] }) -export class DashboardCardLineChartComponent { +export class DashboardCardLineChartComponent implements OnChanges { @Input() data?: CopilotMetrics[]; Highcharts: typeof Highcharts = Highcharts; diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.html b/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.html index e5285ee9..33417542 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.html +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.html @@ -19,7 +19,7 @@

Dashboard

Adoption - + diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts index a8dae939..0cab013d 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts @@ -1,18 +1,17 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { AppModule } from '../../../app.module'; -import { DashboardCardValueComponent } from './dashboard-card/dashboard-card-value/dashboard-card-value.component'; import { MetricsService } from '../../../services/api/metrics.service'; import { CopilotMetrics } from '../../../services/api/metrics.service.interfaces'; -import { ActivityResponse, Seat, SeatService } from '../../../services/api/seat.service'; -import { MembersService } from '../../../services/api/members.service'; +import { ActivityResponse, SeatService } from '../../../services/api/seat.service'; import { CopilotSurveyService, Survey } from '../../../services/api/copilot-survey.service'; -import { forkJoin, Subject, Subscription, takeUntil } from 'rxjs'; +import { Subject, Subscription, takeUntil } from 'rxjs'; import { AdoptionChartComponent } from '../copilot-value/adoption-chart/adoption-chart.component'; import { DailyActivityChartComponent } from '../copilot-value/daily-activity-chart/daily-activity-chart.component'; import { TimeSavedChartComponent } from '../copilot-value/time-saved-chart/time-saved-chart.component'; import { LoadingSpinnerComponent } from '../../../shared/loading-spinner/loading-spinner.component'; import { InstallationsService } from '../../../services/api/installations.service'; import { StatusComponent } from './status/status.component'; +import { Targets, TargetsService } from '../../../services/api/targets.service'; @Component({ selector: 'app-dashboard', @@ -33,6 +32,7 @@ export class CopilotDashboardComponent implements OnInit, OnDestroy { subscriptions = [] as Subscription[]; metricsData?: CopilotMetrics[]; activityData?: ActivityResponse; + targetsData?: Targets; surveysData?: Survey[]; chartOptions: Highcharts.Options = { chart: { @@ -99,12 +99,13 @@ export class CopilotDashboardComponent implements OnInit, OnDestroy { { title: 'Target Levels Acquired', statusMessage: '0 Levels Acquired' } ]; + constructor( private metricsService: MetricsService, - private membersService: MembersService, private seatService: SeatService, private surveyService: CopilotSurveyService, private installationsService: InstallationsService, + private targetsService: TargetsService, private cdr: ChangeDetectorRef ) { } @@ -166,6 +167,14 @@ export class CopilotDashboardComponent implements OnInit, OnDestroy { this.cdr.detectChanges(); }) ) + + this.subscriptions.push( + this.targetsService.getTargets().subscribe(data => { + this.targetsData = data; + this.cdr.detectChanges(); + }) + ); + }); } diff --git a/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts b/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts index 5758d690..8f6499d6 100644 --- a/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts +++ b/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { DateRangeSelectComponent } from "../../../shared/date-range-select/date-range-select.component"; import { MetricsService } from '../../../services/api/metrics.service'; import { CopilotMetrics } from '../../../services/api/metrics.service.interfaces'; @@ -34,7 +34,7 @@ import { DashboardCardLineChartComponent } from '../copilot-dashboard/dashboard- '../copilot-dashboard/dashboard.component.scss' ] }) -export class CopilotMetricsComponent implements OnInit { +export class CopilotMetricsComponent implements OnInit, OnDestroy { metrics?: CopilotMetrics[]; metricsTotals?: CopilotMetrics; installation?: Installation = undefined; @@ -85,10 +85,6 @@ export class CopilotMetricsComponent implements OnInit { this.reset(); - console.log({ - since: event.start.toISOString(), - until: event.end.toISOString() - }) this.subscriptions.push( this.seatService.getActivityTotals({ org: this.installation?.account?.login, diff --git a/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.html b/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.html index cf8d695f..a417960f 100644 --- a/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.html +++ b/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.html @@ -1,32 +1,116 @@ -
- - + + (e.g., Answering Questions, Planning and Deciding, Coding, Writing Tests or Debugging, Something Else? ) + savings most likely show up? + Time Savings can be used many ways, curious about where you think it will make a difference. Faster PR's @@ -150,4 +192,55 @@

Thank you for reading this! Your input helps the developer community learn from each other how to use it effectively. Every insight counts!

- \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts b/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts index 082c4acd..b5bacd62 100644 --- a/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts +++ b/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts @@ -1,18 +1,41 @@ import { Component, forwardRef, OnInit } from '@angular/core'; -import { AppModule } from '../../../../app.module'; -import { AbstractControl, FormBuilder, FormControl, FormGroup, NG_VALUE_ACCESSOR, ValidationErrors, Validators } from '@angular/forms'; +import { CommonModule } from '@angular/common'; // Ensure CommonModule is imported +import { RouterModule } from '@angular/router'; // Import RouterModule +import { ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms'; // Import AbstractControl and ValidationErrors +import { FormBuilder, FormControl, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { CopilotSurveyService, Survey } from '../../../../services/api/copilot-survey.service'; import { ActivatedRoute, Params, Router } from '@angular/router'; -import { MembersService } from '../../../../services/api/members.service'; +import { MembersService, Member } from '../../../../services/api/members.service'; import { InstallationsService } from '../../../../services/api/installations.service'; -import { catchError, map, Observable, of } from 'rxjs'; +import { BehaviorSubject, catchError, finalize, map, Observable, of, Subject } from 'rxjs'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatIconModule } from '@angular/material/icon'; // Import MatIconModule +import { MatFormFieldModule } from '@angular/material/form-field'; // Import MatFormFieldModule +import { MatInputModule } from '@angular/material/input'; // Import MatInputModule +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; // Updated import +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; // Import MatProgressSpinnerModule +import { MatRadioModule } from '@angular/material/radio'; // Import MatRadioModule +import { MatCardModule } from '@angular/material/card'; // Import MatCardModule +import { MatSliderModule } from '@angular/material/slider'; // Import MatSliderModule +import { debounceTime, distinctUntilChanged, filter, switchMap } from 'rxjs/operators'; +import { Seat, SeatService } from '../../../../services/api/seat.service'; +import dayjs from "dayjs"; export function userIdValidator(membersService: MembersService) { return (control: AbstractControl): Observable => { - return membersService.getMemberByLogin(control.value).pipe( - map(isValid => (isValid ? null : { invalidUserId: true })), - catchError(() => of({ invalidUserId: true })) + const value = control.value; + // Extract the login string if the value is a Member object, otherwise use the string directly + const loginToValidate = (typeof value === 'object' && value?.login) ? value.login : value; + + // Ensure we have a non-empty string to validate + if (typeof loginToValidate !== 'string' || loginToValidate.trim() === '') { + // Return null if empty or not a string, let 'required' validator handle emptiness + return of(null); + } + + return membersService.getMemberByLogin(loginToValidate, true).pipe( // Use exact=true for final validation + map(member => (member ? null : { invalidUserId: true })), + catchError(() => of({ invalidUserId: true })) // Assume error means invalid ); }; } @@ -21,8 +44,18 @@ export function userIdValidator(membersService: MembersService) { selector: 'app-copilot-survey', standalone: true, imports: [ - AppModule, - MatTooltipModule + CommonModule, // Use CommonModule instead of BrowserModule + RouterModule, // Add RouterModule to enable routerLink + ReactiveFormsModule, // Add ReactiveFormsModule to enable formGroup + MatTooltipModule, + MatIconModule, // Add MatIconModule to enable mat-icon + MatFormFieldModule, // Add MatFormFieldModule to enable mat-form-field + MatInputModule, // Add MatInputModule to enable matInput + MatAutocompleteModule, // Add MatAutocompleteModule to enable matAutocomplete + MatProgressSpinnerModule, // Add MatProgressSpinnerModule to enable mat-spinner + MatRadioModule, // Add MatRadioModule to enable mat-radio-button + MatCardModule, // Add MatCardModule to enable mat-card + MatSliderModule // Add MatSliderModule to enable mat-slider ], providers: [ { @@ -32,7 +65,7 @@ export function userIdValidator(membersService: MembersService) { } ], templateUrl: './new-copilot-survey.component.html', - styleUrl: './new-copilot-survey.component.scss' + styleUrls: ['./new-copilot-survey.component.scss'] }) export class NewCopilotSurveyComponent implements OnInit { surveyForm: FormGroup; @@ -40,7 +73,22 @@ export class NewCopilotSurveyComponent implements OnInit { defaultPercentTimeSaved = 25; id: number; surveys: Survey[] = []; - orgFromApp: string = ''; + orgFromApp = ''; + hasQueryParams = false; + + // Add these properties to fix the template error + repo = ''; + prNumber = ''; + + // Use a subject to trigger searches + private searchTerms = new Subject(); + // Use BehaviorSubject for loading state + isLoading$ = new BehaviorSubject(false); + // Results observable + filteredMembers$: Observable; + copilotActivityHours = 0; + hasCopilotActivity = false; + assignee_id?: number; // Declare the assignee_id property constructor( private fb: FormBuilder, @@ -48,14 +96,14 @@ export class NewCopilotSurveyComponent implements OnInit { private route: ActivatedRoute, private router: Router, private membersService: MembersService, - private installationsService: InstallationsService + private installationsService: InstallationsService, + private seatService: SeatService // Add the SeatService ) { const id = Number(this.route.snapshot.paramMap.get('id')); this.id = isNaN(id) ? 0 : id; this.surveyForm = this.fb.group({ userId: new FormControl('', { - validators: Validators.required, - asyncValidators: userIdValidator(this.membersService), + validators: Validators.required, // Keep only the required validator }), repo: new FormControl(''), prNumber: new FormControl(''), @@ -64,21 +112,94 @@ export class NewCopilotSurveyComponent implements OnInit { reason: new FormControl(''), timeUsedFor: new FormControl('', Validators.required) }); + + // Set up the search pipeline + this.filteredMembers$ = this.searchTerms.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((term: string) => { + if (term.length < 2) { + return of([]); + } + + this.isLoading$.next(true); + return this.membersService.searchMembersByLogin(term).pipe( + catchError(error => { + console.error('Error searching members:', error); + return of([]); + }), + map(members => { + this.isLoading$.next(false); + return members; + }) + ); + }) + ); } ngOnInit() { + // Set up form event listeners + this.surveyForm.get('userId')?.valueChanges.subscribe(value => { + if (typeof value === 'string') { + this.searchTerms.next(value); + } + }); + + // Initial form setup from query params this.route.queryParams.subscribe(params => { this.params = params; - this.surveyForm.get('userId')?.setValue(params['author']); - }); - // Subscribe to the installationsService to get the latest organization - this.installationsService.currentInstallation.subscribe(installation => { - this.orgFromApp = installation?.account?.login || ''; + // Set hasQueryParams BEFORE setting values to avoid form validation errors + this.hasQueryParams = !!(params['author'] || params['repo'] || params['prno'] || params['url']); + + // Pre-fill the form only if params exist + if (params['author']) { + this.surveyForm.get('userId')?.setValue(params['author']); + + // Manually trigger activity lookup for pre-filled userID + this.loadUserCopilotActivity(params['author']); + } + + if (params['repo']) { + this.surveyForm.get('repo')?.setValue(params['repo']); + this.repo = params['repo']; // Immediately set the property for template access + } + + if (params['prno']) { + this.surveyForm.get('prNumber')?.setValue(params['prno']); + this.prNumber = params['prno']; // Immediately set the property for template access + } + + // Handle GitHub URL parsing + if (params['url']) { + const parsedUrl = new URL(params['url']); + const allowedHosts = ['github.com', 'www.github.com']; + if (allowedHosts.includes(parsedUrl.host)) { + const { org, repo, prNumber } = this.parseGitHubPRUrl(params['url']); + this.orgFromApp = org; + if (!params['repo'] && repo) { + this.surveyForm.get('repo')?.setValue(repo); + this.repo = repo; + } + if (!params['prno'] && prNumber) { + this.surveyForm.get('prNumber')?.setValue(prNumber); + this.prNumber = String(prNumber); + } + } + } }); + if (!this.orgFromApp) { + this.installationsService.currentInstallation.subscribe(installation => { + this.orgFromApp = installation?.account?.login || ''; + }); + } else { + // set organization in installationsService + } + this.loadHistoricalReasons(); + // Handle Copilot usage toggle this.surveyForm.get('usedCopilot')?.valueChanges.subscribe((value) => { if (!value) { this.surveyForm.get('percentTimeSaved')?.setValue(0); @@ -86,6 +207,30 @@ export class NewCopilotSurveyComponent implements OnInit { this.surveyForm.get('percentTimeSaved')?.setValue(this.defaultPercentTimeSaved); } }); + + const id = this.route.snapshot.paramMap.get('id'); + this.id = id ? Number(id) : 0; // Correct type conversion + + // Add user ID value changes listener to fetch activity data when a user is selected + this.surveyForm.get('userId')?.valueChanges.pipe( + filter(value => value && (typeof value === 'object' || value.length > 2)), // Filter empty or too short values + debounceTime(500) // Debounce to avoid too many requests during typing + ).subscribe(value => { + // Get the login from either a member object or string + const login = typeof value === 'object' && value?.login ? value.login : value; + if (login && typeof login === 'string') { + this.loadUserCopilotActivity(login); + } + }); + + // Subscribe to form value changes to keep properties in sync + this.surveyForm.get('repo')?.valueChanges.subscribe(value => { + this.repo = value; + }); + + this.surveyForm.get('prNumber')?.valueChanges.subscribe(value => { + this.prNumber = value; + }); } loadHistoricalReasons() { @@ -94,8 +239,7 @@ export class NewCopilotSurveyComponent implements OnInit { org: this.orgFromApp }).subscribe((surveys: Survey[]) => { this.surveys = surveys; - } - ); + }); } addKudos(survey: Survey) { @@ -125,29 +269,79 @@ export class NewCopilotSurveyComponent implements OnInit { } onSubmit() { - const { org, repo, prNumber } = this.parseGitHubPRUrl(this.params['url']); - const survey = { - id: this.id, - userId: this.surveyForm.value.userId, - org: org || this.orgFromApp, - repo: repo || this.surveyForm.value.repo, - prNumber: prNumber || this.surveyForm.value.prNumber, - usedCopilot: this.surveyForm.value.usedCopilot, - percentTimeSaved: Number(this.surveyForm.value.percentTimeSaved), - reason: this.surveyForm.value.reason, - timeUsedFor: this.surveyForm.value.timeUsedFor - }; - if (!this.id) { - this.copilotSurveyService.createSurvey(survey).subscribe(() => { - this.router.navigate(['/copilot/survey']); + if (this.surveyForm.invalid) { + // Mark all fields as touched to show validation errors + Object.keys(this.surveyForm.controls).forEach(key => { + this.surveyForm.get(key)?.markAsTouched(); }); - } else { - this.copilotSurveyService.createSurveyGitHub(survey).subscribe(() => { - const redirectUrl = this.params['url']; - if (redirectUrl && redirectUrl.startsWith('https://github.com/')) { - window.location.href = redirectUrl; + return; + } + + // Validate the userId field using the userIdValidator before submission + const userIdControl = this.surveyForm.get('userId'); + if (userIdControl && userIdControl.valid) { + try { + // Ensure userId is the login string before sending + const userIdValue = userIdControl.value; + const finalUserId = (typeof userIdValue === 'object' && userIdValue?.login) ? userIdValue.login : userIdValue; + + // Use fallbacks for org and repo + const { org, repo, prNumber } = this.parseGitHubPRUrl(this.params['url'] || ''); + + const survey = { + id: this.id, + userId: finalUserId, + org: org || this.orgFromApp || 'default-org', // Add fallback + repo: repo || this.surveyForm.value.repo || '', + // Fix: Convert null to 0 to match required type + prNumber: prNumber || Number(this.surveyForm.value.prNumber) || 0, // Use 0 instead of null + usedCopilot: Boolean(this.surveyForm.value.usedCopilot), + percentTimeSaved: Number(this.surveyForm.value.percentTimeSaved), + reason: this.surveyForm.value.reason || '', + timeUsedFor: this.surveyForm.value.timeUsedFor || '' + }; + + if (!this.id) { + this.copilotSurveyService.createSurvey(survey).pipe( + catchError(error => { + console.error('Error creating survey:', error); + alert('Failed to submit survey. Please try again.'); + return of(null); + }) + ).subscribe(result => { + if (result) { + this.router.navigate(['/copilot/survey']); + } + }); } else { - console.error('Unauthorized URL:', redirectUrl); + this.copilotSurveyService.createSurveyGitHub(survey).pipe( + catchError(error => { + console.error('Error creating GitHub survey:', error); + alert('Failed to submit survey. Please try again.'); + return of(null); + }) + ).subscribe(result => { + if (result) { + const redirectUrl = this.params['url']; + if (redirectUrl && redirectUrl.startsWith('https://github.com/')) { + window.location.href = redirectUrl; + } else { + console.error('Unauthorized URL:', redirectUrl); + this.router.navigate(['/copilot/survey']); + } + } + }); + } + } catch (error) { + console.error('Error in form submission:', error); + alert('An unexpected error occurred. Please try again.'); + } + } else if (userIdControl) { + // If control is invalid, trigger validation explicitly to show error + userIdControl.markAsTouched(); + userIdValidator(this.membersService)(userIdControl).subscribe(validationResult => { + if (validationResult) { + userIdControl.setErrors(validationResult); } }); } @@ -156,4 +350,139 @@ export class NewCopilotSurveyComponent implements OnInit { formatPercent(value: number) { return `${value}%` } + + displayFn(member: Member | string | null): string { + if (!member) return ''; + return typeof member === 'string' ? member : member.login || ''; + } + + /** + * Handle when an option is selected from the autocomplete dropdown + */ + onMemberSelected(event: MatAutocompleteSelectedEvent): void { + const selectedMember = event.option.value as Member; + + // Set the value in the form and clear errors + const userIdControl = this.surveyForm.get('userId'); + if (userIdControl) { + userIdControl.setValue(selectedMember); + userIdControl.setErrors(null); + } + } + + /** + * Handle blur event on the userId input field + */ + onUserIdBlur(): void { + // Add a small delay to allow the optionSelected event to process first + setTimeout(() => { + const userIdControl = this.surveyForm.get('userId'); + const userId = userIdControl?.value; + + // Skip validation if the value is already a Member object (meaning an option was selected) + if (userId && typeof userId !== 'string' && userId.login) { + return; + } + + // Otherwise, proceed with validation for the string value + this.validateUserIdOnBlur(); + }, 100); // 100ms delay, adjust if needed + } + + /** + * Validates the userId when the input field loses focus (and no option was selected) + * Uses the getMemberByLogin method with exact=true for case-insensitive validation + * Then replaces the input with the correctly cased username + */ + validateUserIdOnBlur(): void { + const userIdControl = this.surveyForm.get('userId'); + const userId = userIdControl?.value; + + // Skip validation if empty (let the required validator handle this) + if (!userId) { + return; + } + + // Double-check: If the value is somehow a Member object, it's valid + if (typeof userId !== 'string' && userId.login) { + userIdControl?.setErrors(null); + return; + } + + if (typeof userId === 'string') { + this.isLoading$.next(true); + + this.membersService.getMemberByLogin(userId, true).pipe( + catchError(error => { + console.error('Error validating user ID:', error); + userIdControl?.setErrors({ invalidUserId: true }); + return of(null); + }), + finalize(() => { + this.isLoading$.next(false); + }) + ).subscribe(result => { + if (result) { + // Valid user - clear errors + userIdControl?.setErrors(null); + + // Always update to the correctly cased username from the API + userIdControl?.setValue(result); + } else { + // Invalid user (and not caught by catchError, e.g., API returned null) + if (!userIdControl?.hasError('invalidUserId')) { // Avoid overwriting existing error + userIdControl?.setErrors({ invalidUserId: true }); + } + } + }); + } + } + + /** + * Loads Copilot activity data for the specified user for the past 7 days + * and calculates the total hours of activity + */ + loadUserCopilotActivity(login: string) { + // Calculate 7 days ago from now + const since = dayjs().subtract(7, 'day').toISOString(); + const until = dayjs().toISOString(); + + this.seatService.getSeatByLogin(login, { since, until }).subscribe({ + next: (activity: Seat[]) => { + if (activity && activity.length > 0) { + // Count unique last_activity_at timestamps + const uniqueTimestamps = new Set(); + activity.forEach(item => { + if (item.last_activity_at) { + // Round to the hour to group closely timed activities + const hourTimestamp = dayjs(item.last_activity_at).startOf('hour').format(); + uniqueTimestamps.add(hourTimestamp); + + // This property doesn't exist on the item because it's not in the + // Seat type definition in your service + this.assignee_id = item.assignee_id; + } + }); + + // Calculate total hours of activity + this.copilotActivityHours = uniqueTimestamps.size; + this.hasCopilotActivity = this.copilotActivityHours > 0; + + + // Pre-select "Yes" for usedCopilot if there's recent activity + if (this.hasCopilotActivity) { + this.surveyForm.get('usedCopilot')?.setValue(true); + } + } else { + this.hasCopilotActivity = false; + this.copilotActivityHours = 0; + } + }, + error: (error) => { // Add type annotation here + console.error('Error fetching user Copilot activity:', error); + this.hasCopilotActivity = false; + this.copilotActivityHours = 0; + } + }); + } } 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 023a4a4e..b0a72a27 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 @@ -85,7 +85,7 @@ export class CopilotValueModelingComponent implements OnInit { ngOnInit() { this.installationsService.currentInstallation.pipe( takeUntil(this._destroy$.asObservable()) - ).subscribe(installation => { + ).subscribe(() => { // subscribe to installation to have installation specific targets try { this.targetsService.getTargets().subscribe(targets => { this.orgDataSource = this.transformTargets(targets.org); diff --git a/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts b/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts index fb486de3..d57da0bf 100644 --- a/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts @@ -24,11 +24,12 @@ export class AdoptionChartComponent implements OnInit, OnChanges { totalUsers = 500; @Input() data?: ActivityResponse; @Input() targets?: Targets; + @Input() yMax = 1111; @Input() chartOptions?: Highcharts.Options; _chartOptions: Highcharts.Options = { yAxis: { title: { - text: 'Adoption %' + text: 'Adoption (% of Max Devs)' }, min: 0, max: 100, @@ -57,7 +58,9 @@ export class AdoptionChartComponent implements OnInit, OnChanges { style: { color: 'var(--sys-primary)' } - } + }, + zIndex: 2 + }] }, series: [{ @@ -89,8 +92,8 @@ export class AdoptionChartComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges) { - if (changes['data'] && this.data) { - const options = this.highchartsService.transformActivityMetricsToLine(this.data); + if (this.data && this.yMax) { + const options = this.highchartsService.transformActivityMetricsToLine(this.data, this.yMax); this._chartOptions = { ...this._chartOptions, ...options, @@ -110,7 +113,7 @@ export class AdoptionChartComponent implements OnInit, OnChanges { } this.updateFlag = true; setTimeout(() => { - (this.chart?.yAxis[0] as any).plotLinesAndBands[0].render(); + this.chart?.yAxis[0].update({}); }, 2000) } } diff --git a/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.html b/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.html index e584bd74..24ee1080 100644 --- a/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.html +++ b/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.html @@ -1,2 +1,6 @@ - + \ No newline at end of file diff --git a/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts b/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts index 60d23271..61af0afe 100644 --- a/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts @@ -35,10 +35,26 @@ export class DailyActivityChartComponent implements OnInit, OnChanges { 'IDE Suggestions': this.targets?.user.dailySuggestions.target || 0, 'IDE Accepts': this.targets?.user.dailyAcceptances.target || 0, 'IDE Chats': this.targets?.user.dailyChatTurns.target || 0, - '.COM Chats': this.targets?.user.dailyDotComChats.target || 0 + '.COM Chats': this.targets?.user.dailyDotComChats.target || 0, + 'IDE Acceptance Rate': 100 * ( this.targets?.user.dailyAcceptances.target || 0) / (this.targets?.user.dailySuggestions.target || 0), // NEW + 'Pull Requests': this.targets?.user.weeklyPRSummaries.target || 0 / 5 // 5 days in a week }; + // NEW: mapping for typical ranges (from, to) + const typicalRangeMapping: Record = { + 'IDE Suggestions': [50, 90], + 'IDE Accepts': [15, 40], + 'IDE Chats': [25, 40], + '.COM Chats': [4, 8], + 'IDE Acceptance Rate': [20, 40], // NEW – percentage range (example) + 'Pull Requests': [1, 3] // NEW example range + }; + + // the code below is to set the target line on the chart, based on the series name + // and the target value from the targets service. Target line only shows if the series is visible and other series are not let newTarget = 1000; + let newTypicalFrom = 50; + let newTypicalTo = 90; const visibleSeries = this.chart.series.filter(s => s.visible); if (visibleSeries.length === 1) { @@ -46,13 +62,20 @@ export class DailyActivityChartComponent implements OnInit, OnChanges { if (series.name && targetMapping[series.name]) { newTarget = targetMapping[series.name]; } + // NEW: select typical range limits + if (series.name && typicalRangeMapping[series.name]) { + [newTypicalFrom, newTypicalTo] = typicalRangeMapping[series.name]; + } } // Use chart instance to access yAxis const yAxis = this.chart.yAxis[0]; const plotLineId = 'target-line'; + const plotBandId = 'typical-range'; yAxis.removePlotLine(plotLineId); + yAxis.removePlotBand?.(plotBandId); + yAxis.addPlotLine({ id: plotLineId, value: newTarget, @@ -68,25 +91,42 @@ export class DailyActivityChartComponent implements OnInit, OnChanges { }, zIndex: 2 }); + + // also show typical range band only when one series is visible + if (visibleSeries.length === 1) { + yAxis.addPlotBand({ + id: plotBandId, + from: newTypicalFrom, + to: newTypicalTo, + color: 'var(--sys-surface-variant)', + label: { + text: 'Typical Range', + style: { color: 'var(--sys-on-surface-variant)' } + }, + zIndex: 1 + }); + + // NEW: tighten y-axis max for the lone series + const dataMax = Math.max( + ...visibleSeries[0].data.filter(v => typeof v === 'number') as number[] + ); + const proposedMax = Math.max(dataMax, newTarget, newTypicalTo) * 1.15; // 15 % head-room + + yAxis.setExtremes(undefined, proposedMax, false); + } else { + // reset to auto when multiple series are active + yAxis.setExtremes(undefined, undefined, false); + } } } }, yAxis: { title: { - text: 'Daily Activity Per Avg User' + text: 'Daily Activity (per Avg User)' }, min: 0, - plotBands: [{ - from: 500, - to: 750, - color: 'var(--sys-surface-variant)', - label: { - text: 'Typical Range', - style: { - color: 'var(--sys-on-surface-variant)' - } - } - }] + maxPadding: 0.2, // was 1.2 – shrink default empty space + plotBands: [] // start with no typical-range band }, tooltip: { headerFormat: '{point.x:%b %d, %Y}
', @@ -100,19 +140,36 @@ export class DailyActivityChartComponent implements OnInit, OnChanges { fontSize: '14px' } }, - series: [{ - name: 'IDE Suggestions', - type: 'spline', - }, { - name: 'IDE Accepts', - type: 'spline', - }, { - name: 'IDE Chats', - type: 'spline', - }, { - name: '.COM Chats', - type: 'spline', - }] + series: [ + { name: 'IDE Suggestions', type: 'spline', data: [], zIndex: 5 }, + { name: 'IDE Accepts', type: 'spline', data: [], zIndex: 4 }, + { name: 'IDE Acceptance Rate', type: 'spline', data: [], zIndex: 3 }, + { name: 'IDE Chats', type: 'spline', data: [], color: '#00E676', zIndex: 5 }, + { name: '.COM Chats', type: 'spline', data: [], color: '#E91E63', zIndex: 4 }, + { name: 'Pull Requests', type: 'spline', data: [], color: '#9C27B0', zIndex: 3 } + ], + plotOptions: { + series: { + events: { + legendItemClick: function () { + const chart = this.chart as Highcharts.Chart & { _isolated?: Highcharts.Series }; + + // if this series is already isolated → restore all + if (chart._isolated === this) { + chart.series.forEach(s => s.setVisible(true, false)); + chart._isolated = undefined; + } else { + // isolate the clicked series + chart.series.forEach(s => s.setVisible(s === this, false)); + chart._isolated = this; + } + + chart.redraw(false); + return false; // prevent default toggle + } + } + } + } }; constructor( @@ -131,7 +188,8 @@ export class DailyActivityChartComponent implements OnInit, OnChanges { ...this._chartOptions, ...this.highchartsService.transformMetricsToDailyActivityLine(this.activity, this.metrics) }; - this.updateFlag = true; + // toggle so detects a change + this.updateFlag = !this.updateFlag; } } 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 b9ddea60..8ceea2e2 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 @@ -26,10 +26,10 @@ export class TimeSavedChartComponent implements OnInit, OnChanges { _chartOptions: Highcharts.Options = { yAxis: { title: { - text: 'Time Saved (hrs per week)' + text: 'Time Saved (Hrs per Week)' }, min: 0, - // max: 12, + max: 10, labels: { format: '{value}hrs' }, diff --git a/frontend/src/app/main/copilot/copilot-value/value.component.html b/frontend/src/app/main/copilot/copilot-value/value.component.html index 319a9546..66a1088e 100644 --- a/frontend/src/app/main/copilot/copilot-value/value.component.html +++ b/frontend/src/app/main/copilot/copilot-value/value.component.html @@ -10,7 +10,13 @@

Value

Adoption - + +
diff --git a/frontend/src/app/main/copilot/copilot-value/value.component.ts b/frontend/src/app/main/copilot/copilot-value/value.component.ts index 295e8254..5bb1da77 100644 --- a/frontend/src/app/main/copilot/copilot-value/value.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/value.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { AppModule } from '../../../app.module'; import { AdoptionChartComponent } from "./adoption-chart/adoption-chart.component"; import { ActivityResponse, SeatService } from '../../../services/api/seat.service'; @@ -32,7 +32,7 @@ HC_full_screen(Highcharts); // '../copilot-dashboard/dashboard.component.scss' ] }) -export class CopilotValueComponent implements OnInit { +export class CopilotValueComponent implements OnInit, OnDestroy { activityData?: ActivityResponse; metricsData?: CopilotMetrics[]; targetsData?: Targets; diff --git a/frontend/src/app/main/main.component.html b/frontend/src/app/main/main.component.html index 42c41062..e57f33d8 100644 --- a/frontend/src/app/main/main.component.html +++ b/frontend/src/app/main/main.component.html @@ -67,7 +67,7 @@

} - {{ installations[0]?.account?.login }} + {{ installations[0].account?.login }}

diff --git a/frontend/src/app/services/api/copilot-survey.service.ts b/frontend/src/app/services/api/copilot-survey.service.ts index fa861362..d2b99405 100644 --- a/frontend/src/app/services/api/copilot-survey.service.ts +++ b/frontend/src/app/services/api/copilot-survey.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { serverUrl } from '../server.service'; @@ -42,6 +42,7 @@ export class CopilotSurveyService { since?: string; until?: string; status?: 'pending' | 'completed'; + userId?: string; }) { if (!params?.org) delete params?.org; return this.http.get(this.apiUrl, { @@ -53,6 +54,10 @@ export class CopilotSurveyService { return this.http.get(`${this.apiUrl}/${id}`); } + getSurveysByUserId(userId: string) { + return this.getAllSurveys({ userId: userId.toString() }); + } + deleteSurvey(id: number) { return this.http.delete(`${this.apiUrl}/${id}`); } diff --git a/frontend/src/app/services/api/members.service.ts b/frontend/src/app/services/api/members.service.ts index 9b1bdc70..4f7d01a0 100644 --- a/frontend/src/app/services/api/members.service.ts +++ b/frontend/src/app/services/api/members.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { serverUrl } from '../server.service'; import { HttpClient } from '@angular/common/http'; import { Endpoints } from '@octokit/types'; -import { catchError } from 'rxjs/operators'; +import { catchError, Observable } from 'rxjs'; import { throwError } from 'rxjs'; export interface Member { @@ -45,7 +45,18 @@ export class MembersService { }); } - getMemberByLogin(login: string) { - return this.http.get(`${this.apiUrl}/${login}`); + getMemberByLogin(login: string, exact = true) { + return this.http.get( + `${this.apiUrl}/${login}`, + { params: { exact: String(exact) } } // make this boolean not string. + ).pipe( + catchError(error => { + return throwError(() => error); + }) + ); + } + + searchMembersByLogin(query: string): Observable { + return this.http.get(`${serverUrl}/api/members/search`, { params: { query } }); } } diff --git a/frontend/src/app/services/api/seat.service.ts b/frontend/src/app/services/api/seat.service.ts index 4b870c8b..bf342f99 100644 --- a/frontend/src/app/services/api/seat.service.ts +++ b/frontend/src/app/services/api/seat.service.ts @@ -1,10 +1,23 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { serverUrl } from '../server.service'; import { Endpoints } from "@octokit/types"; -import { map } from 'rxjs'; +import { Observable, map } from 'rxjs'; + +// Base GitHub API seat type +export type GitHubSeat = NonNullable[0]; + +// Extended seat type with backend-specific fields +export interface Seat extends GitHubSeat { + assignee_id?: number; + assignee_login?: string; + org?: string; + team?: string; + queryAt?: string; + createdAt?: string; + updatedAt?: string; +} -export type Seat = NonNullable[0]; export interface AllSeats { avatar_url: string, login: string, @@ -59,8 +72,32 @@ export class SeatService { }); } - getSeat(id: number | string) { - return this.http.get(`${this.apiUrl}/${id}`); + /** + * Get seat activity for a user by ID with optional date filtering + */ + getSeat(id: string | number, params?: { since?: string; until?: string }): Observable { + let queryParams = new HttpParams(); + if (params?.since) { + queryParams = queryParams.set('since', params.since); + } + if (params?.until) { + queryParams = queryParams.set('until', params.until); + } + return this.http.get(`${this.apiUrl}/${id}`, { params: queryParams }); + } + + /** + * Get seat activity for a user by login with optional date filtering + */ + getSeatByLogin(login: string, params?: { since?: string; until?: string }): Observable { + let queryParams = new HttpParams(); + if (params?.since) { + queryParams = queryParams.set('since', params.since); + } + if (params?.until) { + queryParams = queryParams.set('until', params.until); + } + return this.http.get(`${this.apiUrl}/${login}`, { params: queryParams }); } getActivity(org?: string) { diff --git a/frontend/src/app/services/api/setup.service.ts b/frontend/src/app/services/api/setup.service.ts index a02797b4..2a8dfc05 100644 --- a/frontend/src/app/services/api/setup.service.ts +++ b/frontend/src/app/services/api/setup.service.ts @@ -60,4 +60,4 @@ export class SetupService { return this.http.post(`${this.apiUrl}/db`, request); } -} + } diff --git a/frontend/src/app/services/api/targets.service.ts b/frontend/src/app/services/api/targets.service.ts index a096f3db..86f4ea15 100644 --- a/frontend/src/app/services/api/targets.service.ts +++ b/frontend/src/app/services/api/targets.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { serverUrl } from '../server.service'; +import { of } from 'rxjs'; +import { map, catchError } from 'rxjs/operators'; export interface Target { current: number; @@ -38,12 +40,38 @@ export interface Targets { export class TargetsService { private apiUrl = `${serverUrl}/api/targets`; - constructor( - private http: HttpClient - ) { } + // Default used when the API is empty or errors out + static readonly DEFAULT_TARGETS: Targets = { + org: { + seats: { current: 910, target: 910, max: 1000 }, + adoptedDevs: { current: 569, target: 569, max: 1000 }, + monthlyDevsReportingTimeSavings: { current: 1, target: 2, max: 1000 }, + percentOfSeatsReportingTimeSavings: { current: 0.1, target: 0.2, max: 100 }, + percentOfSeatsAdopted: { current: 62.5, target: 62.5, max: 100 }, + percentOfMaxAdopted: { current: 56.9, target: 56.9, max: 100 } + }, + user: { + dailySuggestions: { current: 122.4, target: 244.8, max: 150 }, + dailyAcceptances: { current: 85.7, target: 102.8, max: 100 }, + dailyChatTurns: { current: 21.6, target: 32.5, max: 50 }, + dailyDotComChats: { current: 2.8, target: 4.2, max: 100 }, + weeklyPRSummaries: { current: 0.3, target: 0.5, max: 5 }, + weeklyTimeSavedHrs: { current: 0, target: 0, max: 10 } + }, + impact: { + monthlyTimeSavingsHrs: { current: 0, target: 0, max: 72800 }, + annualTimeSavingsAsDollars: { current: 0, target: 0, max: 27300000 }, + productivityOrThroughputBoostPercent: { current: 0, target: 0, max: 25 } + } + }; + + constructor(private http: HttpClient) { } getTargets() { - return this.http.get(`${this.apiUrl}`); + return this.http.get(this.apiUrl).pipe( + map(data => data ?? TargetsService.DEFAULT_TARGETS), + catchError(() => of(TargetsService.DEFAULT_TARGETS)) + ); } saveTargets(targets: Targets) { diff --git a/frontend/src/app/services/highcharts.service.ts b/frontend/src/app/services/highcharts.service.ts index 7b18514c..e97665b9 100644 --- a/frontend/src/app/services/highcharts.service.ts +++ b/frontend/src/app/services/highcharts.service.ts @@ -496,14 +496,14 @@ export class HighchartsService { ]; } - transformActivityMetricsToLine(data: ActivityResponse): Highcharts.Options { + transformActivityMetricsToLine(data: ActivityResponse, maxDevs: number): Highcharts.Options { const activeUsersSeries = { name: 'Users', type: 'spline' as const, data: Object.entries(data).map(([date, dateData]) => { return { x: new Date(date).getTime(), - y: (dateData.totalActive / dateData.totalSeats) * 100, + y: (dateData.totalActive / maxDevs) * 100, raw: dateData.totalActive // Store original value for tooltip }; }), @@ -565,6 +565,12 @@ export class HighchartsService { name: 'IDE Accepts', data: [] as CustomHighchartsPointOptions[] }; + // NEW: acceptance-rate (% of suggestions accepted) + const dailyActiveIdeAcceptanceRateSeries = { + ...initialSeries, + name: 'IDE Acceptance Rate', + data: [] as CustomHighchartsPointOptions[] + }; const dailyActiveIdeChatSeries = { ...initialSeries, name: 'IDE Chats', @@ -577,23 +583,38 @@ export class HighchartsService { }; const dailyActiveDotcomPrSeries = { ...initialSeries, - name: '.COM Pull Requests', + name: 'Pull Requests', data: [] as CustomHighchartsPointOptions[] }; - Object.entries(activity).forEach(([date, dateData]) => { + Object.entries(activity).forEach(([date]) => { + // Skip if totalActive is undefined or 0 or there is a data quality issue making daily suggestions per average user > 250 const currentMetrics = metrics.find(m => m.date.startsWith(date.slice(0, 10))); + if (!currentMetrics || (currentMetrics.copilot_ide_code_completions?.total_engaged_users ?? 0) < 1 || ((currentMetrics.copilot_ide_code_completions?.total_code_suggestions ?? 0) / (currentMetrics.copilot_ide_code_completions?.total_engaged_users ?? 1)) > 250) return; + if (currentMetrics?.copilot_ide_code_completions) { + // Suggestions per user (dailyActiveIdeCompletionsSeries.data).push({ x: new Date(date).getTime(), - y: (currentMetrics.copilot_ide_code_completions.total_code_suggestions / (dateData.totalActive || 1)), + y: (currentMetrics.copilot_ide_code_completions.total_code_suggestions / currentMetrics.copilot_ide_code_completions.total_engaged_users), + raw: date + }); + + // Accepts per user + (dailyActiveIdeAcceptsSeries.data).push({ + x: new Date(date).getTime(), + y: (currentMetrics.copilot_ide_code_completions.total_code_acceptances / + currentMetrics.copilot_ide_code_completions.total_engaged_users), raw: date }); - if (dailyActiveIdeAcceptsSeries && dailyActiveIdeAcceptsSeries.data) { - dailyActiveIdeAcceptsSeries.data.push({ + // NEW: acceptance-rate (%) + const sugg = currentMetrics.copilot_ide_code_completions.total_code_suggestions; + const acc = currentMetrics.copilot_ide_code_completions.total_code_acceptances; + if (sugg > 0) { + (dailyActiveIdeAcceptanceRateSeries.data).push({ x: new Date(date).getTime(), - y: (currentMetrics.copilot_ide_code_completions.total_code_acceptances / (dateData.totalActive || 1)), + y: +(acc / sugg * 100).toFixed(2), raw: date }); } @@ -601,21 +622,21 @@ export class HighchartsService { if (currentMetrics?.copilot_ide_chat) { (dailyActiveIdeChatSeries.data).push({ x: new Date(date).getTime(), - y: (currentMetrics.copilot_ide_chat.total_chats / dateData.totalActive || 1), + y: (currentMetrics.copilot_ide_chat.total_chats / currentMetrics.copilot_ide_chat.total_engaged_users), raw: date }); } if (currentMetrics?.copilot_dotcom_chat) { (dailyActiveDotcomChatSeries.data).push({ x: new Date(date).getTime(), - y: (currentMetrics.copilot_dotcom_chat.total_chats / dateData.totalActive || 1), + y: (currentMetrics.copilot_dotcom_chat.total_chats / currentMetrics.copilot_dotcom_chat.total_engaged_users), raw: date }); } if (currentMetrics?.copilot_dotcom_pull_requests) { (dailyActiveDotcomPrSeries.data).push({ x: new Date(date).getTime(), - y: (currentMetrics.copilot_dotcom_pull_requests.total_pr_summaries_created / dateData.totalActive || 1), + y: (currentMetrics.copilot_dotcom_pull_requests.total_pr_summaries_created / currentMetrics.copilot_dotcom_pull_requests.total_engaged_users), raw: date }); } @@ -625,9 +646,10 @@ export class HighchartsService { series: [ dailyActiveIdeCompletionsSeries, dailyActiveIdeAcceptsSeries, + dailyActiveIdeAcceptanceRateSeries, // NEW series added to output dailyActiveIdeChatSeries, dailyActiveDotcomChatSeries, - // dailyActiveDotcomPrSeries, + dailyActiveDotcomPrSeries // ← was commented out ] } } @@ -649,7 +671,7 @@ export class HighchartsService { if (dateSurveys.length > 0) { const avgPercentTimeSaved = dateSurveys.reduce((sum, survey) => sum + survey.percentTimeSaved, 0) - acc[dateKey].sum = avgPercentTimeSaved * 0.01 * 0.3 * 40; // TODO pull settings + acc[dateKey].sum = avgPercentTimeSaved * 0.01 * 0.3 * 40; // TODO pull settings value, right now fixed at 30% time spent coding acc[dateKey].count = dateSurveys.length; } diff --git a/frontend/src/app/shared/directives/thousand-separator.directive.ts b/frontend/src/app/shared/directives/thousand-separator.directive.ts deleted file mode 100644 index 273ea093..00000000 --- a/frontend/src/app/shared/directives/thousand-separator.directive.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Directive, ElementRef, HostListener } from '@angular/core'; - -@Directive({ - selector: '[matThousandSeparator]' -}) -export class ThousandSeparatorDirective { - private regex: RegExp = new RegExp(/^\d+$/); - - constructor(private el: ElementRef) {} - - @HostListener('input', ['$event']) - onInputChange(event: Event) { - const input = event.target as HTMLInputElement; - let value = input.value.replace(/,/g, ''); - if (this.regex.test(value)) { - value = this.formatNumber(value); - input.value = value; - } else { - input.value = input.value.slice(0, -1); - } - } - - private formatNumber(value: string): string { - return value.replace(/\B(?=(\d{3})+(?!\d))/g, ','); - } -} \ No newline at end of file diff --git a/github.ts b/github.ts deleted file mode 100644 index e69de29b..00000000