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/backend/package.json b/backend/package.json index aea157ea..1a876e46 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,10 +5,10 @@ "description": "Demonstrate the value of GitHub", "main": "src/index.ts", "scripts": { - "start": "node --enable-source-maps dist/index.js | bunyan -o short -l info", + "start": "NODE_OPTIONS='--max-old-space-size=6120' node --enable-source-maps dist/index.js | bunyan -o short -l info", "test": "vitest", "build": "tsc", - "dev": "tsx watch src/index.ts | bunyan -o short -l info", + "dev": "NODE_OPTIONS='--max-old-space-size=6120' tsx watch src/index.ts | bunyan -o short -l info", "lint": "eslint src/**/*.ts", "compose:start": "docker-compose -f ../compose.yml up -d", "db:start": "docker-compose -f ../compose.yml up -d mongo", diff --git a/backend/src/controllers/api-docs.controller.ts b/backend/src/controllers/api-docs.controller.ts new file mode 100644 index 00000000..2c6106f8 --- /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 (error) { + res.status(500).json({ error: "Failed to retrieve API documentation" }); + } + } +} + +export default new ApiDocsController(); 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..fe03a443 100644 --- a/backend/src/controllers/survey.controller.ts +++ b/backend/src/controllers/survey.controller.ts @@ -64,37 +64,19 @@ 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 } = req.query as { [key: string]: string | undefined }; + + const surveys = await surveyService.getAllSurveys({ + org, + team, + reasonLength, + since, + until, + status + }); + 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..2ac83e9a 100644 --- a/backend/src/controllers/target.controller.ts +++ b/backend/src/controllers/target.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import TargetValuesService from '../services/target.service.js'; +import { TargetCalculationService } from '../services/target-calculation-service.js'; class TargetValuesController { async getTargetValues(req: Request, res: Response): Promise { @@ -19,6 +20,33 @@ class TargetValuesController { 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 { + const org = req.query.org || 'enterprise'; + const enableLogging = req.query.enableLogging === 'true'; + const includeLogsInResponse = req.query.includeLogs === 'true'; + + // Create an instance of the calculation service + const calculationService = new TargetCalculationService(); + + // Fetch data and calculate targets with optional logging and logs in response + const result = await calculationService.fetchAndCalculateTargets( + org as string, + enableLogging, + includeLogsInResponse + ); + + res.status(200).json(result); + } catch (error) { + console.error('Error calculating target values:', error); + res.status(500).json({ error: 'Failed to calculate target values' }); + } + } } export default new TargetValuesController(); diff --git a/backend/src/controllers/teams.controller.ts b/backend/src/controllers/teams.controller.ts index 41edac7a..5bfd0ad5 100644 --- a/backend/src/controllers/teams.controller.ts +++ b/backend/src/controllers/teams.controller.ts @@ -33,7 +33,8 @@ class TeamsController { async getMemberByLogin(req: Request, res: Response): Promise { try { const { login } = req.params; - const member = teamsService.getMemberByLogin(login); + const exact = req.query.exact === 'true'; + const member = await teamsService.getMemberByLogin(login); if (member) { res.json(member); } else { @@ -43,6 +44,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..93b36d56 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -20,6 +20,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, 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..931005bb --- /dev/null +++ b/backend/src/routes/status.route.ts @@ -0,0 +1,18 @@ +import express from 'express'; +import StatusService from '../services/status.service.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) { + console.error('Error fetching status:', error); + res.status(500).json({ error: 'Failed to fetch status' }); + } +}); + +export default router; diff --git a/backend/src/services/status.service.ts b/backend/src/services/status.service.ts index a489b9d8..37582855 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,6 +14,12 @@ export interface StatusType { repos: Endpoints["GET /app/installations"]["response"]["data"]; }[]; surveyCount: number; + auth?: { + user?: string; + email?: string; + authenticated: boolean; + headers?: string[]; // Add this to store header names + }; } class StatusService { @@ -20,9 +27,22 @@ class StatusService { } - 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; + + status.auth = { + user, + email, + authenticated: !!user, + 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..e2ee8c95 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,49 @@ 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; + }) { + const { org, team, reasonLength, since, until, status } = params; + + 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'); + 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..121b9cbd --- /dev/null +++ b/backend/src/services/target-calculation-service.ts @@ -0,0 +1,929 @@ +import settingsService, { 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'; + +// 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]: any; // Add this index signature +} + +// More specific typed interfaces for metrics data +interface MetricsData { + copilot_ide_code_completions?: { + total_code_suggestions: number; + }; + copilot_ide_chat?: { + total_chats: number; + }; + copilot_dotcom_pull_requests?: { + total_pr_summaries_created: number; + }; + total_active_users: number; +} + +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: Array<{ + name: string; + inputs: Record; + formula: string; + result: any; + }> = []; + + /** + * 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: any): 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 = { + name, + inputs, + formula, + result + }; + + // Store the log entry if debug logging is enabled + if (this.debugLogging) { + this.calculationLogs.push(logEntry); + + // Also print to console + console.log(` +========== CALCULATION: ${name} ========== +INPUTS: +${Object.entries(inputs).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`).join('\n')} + +FORMULA/ALGORITHM: + ${formula} + +RESULT: + ${JSON.stringify(result)} +======================================== +`); + } + } + } + + // 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 = referenceDate; + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + // 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: { enterprise: 'enterprise' }, + projection: {} + }), + metricsService.getMetrics(baseMetricsParams), + metricsService.getMetrics(weeklyMetricsParams), + copilotSurveyService.getAllSurveys(weeklySurveysParams), + copilotSurveyService.getAllSurveys(monthlySurveysParams) + ]); + + this.dataFetched = true; + } + + // === UTILITY CALCULATION METHODS === + + /** + * Calculate percentage with protection against division by zero + */ + calculatePercentage(numerator: number, denominator: number): number { + if (denominator === 0) { + return 0; + } + // Correct calculation: (numerator / denominator) * 100 + return (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 => ({ totalActive: a.totalActive })), + developerCount: this.settings.developerCount, + adoptionsCount: this.adoptions.length + }, + 'Sort adoptions by totalActive, take top 10, average totalActive, 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 + }, + 'Count distinct userIds from monthly surveys, set current = distinctUsers.length, 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 per developer + */ + calculateDailySuggestions(): Target { + const adoptedDevs = this.calculateAdoptedDevs().current; + + // Extract metrics from the 5 largest values in the array, with fallbacks if there are less. + + const metricsWeekly = this.metricsWeekly.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); + const metricsAvg = metricsWeekly.reduce((acc, curr) => { + acc.copilot_ide_code_completions.total_code_suggestions += curr.copilot_ide_code_completions?.total_code_suggestions || 0; + return acc; + } + , { copilot_ide_code_completions: { total_code_suggestions: 0 } }); + metricsAvg.copilot_ide_code_completions.total_code_suggestions /= metricsWeekly.length || 1; + const totalSuggestions = metricsAvg.copilot_ide_code_completions.total_code_suggestions || 0; + const timestamp = metricsWeekly.length > 0 ? new Date(metricsWeekly[0].date).toISOString() : 'unknown'; + const rowCount = this.metricsWeekly?.length || 0; + + const suggestionsPerDev = adoptedDevs > 0 ? totalSuggestions / adoptedDevs : 0; + + const result = { + current: suggestionsPerDev, + target: suggestionsPerDev * 2, // Target is user-defined + max: 150 // Based on frontend hardcoded value + }; + + this.logCalculation( + 'DAILY SUGGESTIONS PER DEVELOPER', + { + totalSuggestions: totalSuggestions, + adoptedDevsCount: adoptedDevs, + metricsRowCount: rowCount, + timestamp: timestamp, + metricsSource: 'metricsDaily[0].copilot_ide_code_completions.total_code_suggestions' + }, + 'Calculate totalSuggestions / adoptedDevs, set current = suggestionsPerDev, max = 150', + result + ); + + return result; + } + + /** + * Calculate daily chat turns per developer + */ + calculateDailyChatTurns(): Target { + const adoptedDevs = this.calculateAdoptedDevs().current; + + // Extract metrics from the 5 most recent days in the array + const metricsDaily = this.metricsDaily.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); + const metricsAvg = metricsDaily.reduce((acc, curr) => { + acc.copilot_ide_chat.total_chats += curr.copilot_ide_chat?.total_chats || 0; + acc.total_active_users += curr.total_active_users || 0; + return acc; + }, { copilot_ide_chat: { total_chats: 0 }, total_active_users: 0 }); + + // Calculate averages + metricsAvg.copilot_ide_chat.total_chats /= metricsDaily.length || 1; + metricsAvg.total_active_users = Math.max(metricsAvg.total_active_users / (metricsDaily.length || 1), 1); // Avoid division by zero + + const totalChats = metricsAvg.copilot_ide_chat.total_chats; + const activeUsers = metricsAvg.total_active_users; + const timestamp = metricsDaily.length > 0 ? new Date(metricsDaily[0].date).toISOString() : 'unknown'; + const rowCount = metricsDaily.length; + + const chatTurnsPerDev = totalChats / activeUsers; + + const result = { + current: chatTurnsPerDev, + target: chatTurnsPerDev * 1.5, // Target is 50% increase + max: 50 // Based on frontend hardcoded value + }; + + this.logCalculation( + 'DAILY CHAT TURNS PER DEVELOPER', + { + totalChats: totalChats, + activeUsersCount: activeUsers, + metricsRowCount: rowCount, + timestamp: timestamp, + metricsSource: 'Average of top 5 recent daily metrics' + }, + 'Calculate average totalChats / activeUsers from 5 most recent days, set target = current * 1.5', + result + ); + + return result; + } + + /** + * Calculate daily acceptances + */ + calculateDailyAcceptances(): Target { + const adoptedDevs = this.calculateAdoptedDevs().current; + + // Extract metrics from the 5 most recent days in the array + const metricsDaily = this.metricsDaily.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); + + // We don't have acceptance data yet, but when we do, we can follow this pattern + // Placeholder formula: roughly 70% of suggestions get accepted + const dailySuggestions = this.calculateDailySuggestions().current; + const acceptanceRate = 0.7; // Placeholder assumption + const acceptancesPerDev = dailySuggestions * acceptanceRate; + + const result = { + current: acceptancesPerDev, + target: acceptancesPerDev * 1.2, // Target is 20% increase + max: 100 + }; + + this.logCalculation( + 'DAILY ACCEPTANCES PER DEVELOPER', + { + dailySuggestions: dailySuggestions, + assumedAcceptanceRate: acceptanceRate, + adoptedDevsCount: adoptedDevs, + metricsRowCount: metricsDaily.length + }, + 'Calculate dailySuggestions * assumedAcceptanceRate (placeholder until actual data available)', + result + ); + + return result; + } + + /** + * Calculate daily dot com chats + */ + calculateDailyDotComChats(): Target { + const adoptedDevs = this.calculateAdoptedDevs().current; + + // Extract metrics from the 5 most recent days in the array + const metricsRecent = this.metricsWeekly.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); + + // Currently we may not have direct dot com chat data in metrics, so we'll look for it or use a calculation + const metricsAvg = metricsRecent.reduce((acc, curr) => { + // Check if we have direct dot com chat data (future-proofing) + if (curr.copilot_dotcom_chat?.total_chats) { + acc.dotcom_chats += curr.copilot_dotcom_chat.total_chats; + acc.has_direct_data = true; + } + return acc; + }, { dotcom_chats: 0, has_direct_data: false }); + + let dotComChatsPerDev = 0; + + if (metricsAvg.has_direct_data) { + // If we have direct data, use it + dotComChatsPerDev = adoptedDevs > 0 ? (metricsAvg.dotcom_chats / metricsRecent.length) / adoptedDevs : 0; + } else { + // Otherwise use our ratio estimation from IDE chat data + const dailyChatTurns = this.calculateDailyChatTurns().current; + const dotComChatRatio = 0.33; // Placeholder assumption + dotComChatsPerDev = dailyChatTurns * dotComChatRatio; + } + + const result = { + current: dotComChatsPerDev, + target: dotComChatsPerDev * 1.5, // Target is 50% increase + max: 100 + }; + + this.logCalculation( + 'DAILY DOTCOM CHATS PER DEVELOPER', + { + dotComChatsTotal: metricsAvg.dotcom_chats, + hasDirectData: metricsAvg.has_direct_data, + fallbackDailyChatTurns: !metricsAvg.has_direct_data ? this.calculateDailyChatTurns().current : null, + assumedDotComRatio: !metricsAvg.has_direct_data ? 0.33 : null, + adoptedDevsCount: adoptedDevs, + metricsRowCount: metricsRecent.length, + metricsTimeRange: metricsRecent.length > 0 ? + `${new Date(metricsRecent[metricsRecent.length-1].date).toISOString()} to ${new Date(metricsRecent[0].date).toISOString()}` : 'unknown' + }, + metricsAvg.has_direct_data ? + 'Average dotcom_chats / adoptedDevs from 5 most recent metrics days' : + 'Calculate dailyChatTurns * assumedDotComRatio (using fallback ratio)', + result + ); + + return result; + } + + /** + * Calculate weekly PR summaries per developer + */ + calculateWeeklyPRSummaries(): Target { + const adoptedDevs = this.calculateAdoptedDevs().current; + + // Extract metrics from the last 5 metrics points in the array (up to a week) + const metricsWeekly = this.metricsWeekly.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); + const metricsAvg = metricsWeekly.reduce((acc, curr) => { + acc.copilot_dotcom_pull_requests.total_pr_summaries_created += + curr.copilot_dotcom_pull_requests?.total_pr_summaries_created || 0; + return acc; + }, { copilot_dotcom_pull_requests: { total_pr_summaries_created: 0 } }); + + // Calculate average + metricsAvg.copilot_dotcom_pull_requests.total_pr_summaries_created /= metricsWeekly.length || 1; + + const totalPRSummaries = metricsAvg.copilot_dotcom_pull_requests.total_pr_summaries_created; + const timestamp = metricsWeekly.length > 0 ? new Date(metricsWeekly[0].date).toISOString() : 'unknown'; + const rowCount = metricsWeekly.length; + + const prSummariesPerDev = adoptedDevs > 0 ? totalPRSummaries / adoptedDevs : 0; + + const result = { + current: prSummariesPerDev, + target: prSummariesPerDev * 2, // Target is double current + max: 5 // Based on frontend hardcoded value + }; + + this.logCalculation( + 'WEEKLY PR SUMMARIES PER DEVELOPER', + { + totalPRSummaries: totalPRSummaries, + adoptedDevsCount: adoptedDevs, + metricsRowCount: rowCount, + timestamp: timestamp, + metricsSource: 'Average of recent weekly metrics' + }, + 'Calculate average totalPRSummaries / adoptedDevs, set target = current * 2', + result + ); + + return result; + } + + /** + * 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: avgWeeklyTimeSaved, + target: Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8), // Target is 50% increase, capped at 80% of max + max: 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: monthlyTimeSavings, + target: 0, // Target is user-defined + max: 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; + + const result = { + current: annualSavings || 0, // Ensure non-null + target: 0, + max: 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; + const monthlyTimeSavings = adoptedDevs * weeklyTimeSavedHrs * 4; // Assuming 4 weeks per month + + // 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; + + const result = { + current: productivityBoost, + target: 10, // Target is user-defined + max: 25 // Based on target.service.ts + }; + + this.logCalculation( + 'PRODUCTIVITY OR THROUGHPUT BOOST PERCENT', + { + adoptedDevsCount: adoptedDevs, + weeklyTimeSavedHrs: weeklyTimeSavedHrs, + monthlyTimeSavings: monthlyTimeSavings, + hoursPerWeek: hoursPerWeek, + productivityBoost: productivityBoost + }, + 'Calculate (hoursPerWeek + weeklyTimeSavedHrs) / hoursPerWeek, set current = productivityBoost, max = 25', + 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?: Array }> { + this.debugLogging = enableLogging; + this.resetLogging(); // Reset logging state + console.log(`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?: Array }> { + const service = new TargetCalculationService(); + return service.fetchAndCalculateTargets(org, enableLogging, includeLogsInResponse); + } +} + +// Allow isolated testing +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); + console.log('Calculated Targets:', JSON.stringify(result.targets, null, 2)); + console.log(`Returned ${result.logs?.length || 0} calculation logs`); + })(); +} diff --git a/backend/src/services/target.service.ts b/backend/src/services/target.service.ts index 4db20640..c1ab9068 100644 --- a/backend/src/services/target.service.ts +++ b/backend/src/services/target.service.ts @@ -2,6 +2,7 @@ import mongoose from 'mongoose'; import adoptionService, { AdoptionType } from './adoption.service.js'; import app from '../index.js'; import { SettingsType } from './settings.service.js'; +import { TargetCalculationService } from './target-calculation-service.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); @@ -99,19 +105,25 @@ class TargetValuesService { }; } - async initialize() { + calculateTargets(settings: SettingsType, adoptions: AdoptionType[]): Promise<{ targets: Targets; logs?: any[] }> { + return TargetCalculationService.fetchAndCalculateTargets(null, false, false); //always true for enableLogging for now to audit calculations, always false for includeLogsInResponse. + } + + //create default targets if they don't exist + async initialize() { try { const Targets = mongoose.model('Targets'); const existingTargets = await Targets.findOne(); - if (!existingTargets) { + if (!existingTargets || true) { const settings = await app.settingsService.getAllSettings(); const adoptions = await adoptionService.getAllAdoptions2({ filter: { enterprise: 'enterprise' }, projection: {} }); - const initialData = this.calculateTargets(settings, adoptions); + const initialData = await this.calculateTargets(settings, adoptions); await Targets.create(initialData); + console.log('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..d0ff6402 100644 --- a/backend/src/services/teams.service.ts +++ b/backend/src/services/teams.service.ts @@ -181,6 +181,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/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts b/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts index d8f72e98..15c8a9b7 100644 --- a/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts +++ b/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts @@ -90,15 +90,13 @@ export class CopilotSeatComponent implements OnInit { ...this.chartOptions, ...this._chartOptions }; - this.timeSpent = dayjs.duration({ + this.timeSpent = " ~ " + Math.floor(dayjs.duration({ milliseconds: (this.chartOptions.series as Highcharts.SeriesGanttOptions[])?.reduce((total, series) => { return total += series.data?.reduce((dataTotal, data) => dataTotal += (data.end || 0) - (data.start || 0), 0) || 0; }, 0) - }).humanize(); + }).asHours()).toString() + " hrs"; //.humanize(); this.updateFlag = true; this.cdr.detectChanges(); }); - } - } diff --git a/frontend/src/app/main/copilot/copilot-surveys/github.service.ts b/frontend/src/app/main/copilot/copilot-surveys/github.service.ts new file mode 100644 index 00000000..75ad7688 --- /dev/null +++ b/frontend/src/app/main/copilot/copilot-surveys/github.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class GithubService { + private apiUrl = 'http://localhost:8080/api'; // Replace with your actual API base URL + + constructor(private http: HttpClient) {} + + getOrgMembers(): Observable { + return this.http.get(`${this.apiUrl}/members`); + } +} diff --git a/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.html b/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.html index 1693a3c1..396c7b73 100644 --- a/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.html +++ b/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.html @@ -13,13 +13,35 @@
Estimate Time Savings
-
+
+ GitHub Handle - + + + + + + Loading... + + + + + {{ member.login }} + + - Invalid GitHub Handle + This GitHub user was not found + + + GitHub handle is required 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..b6d8e6d9 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,39 @@ 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, startWith, take } 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, tap } from 'rxjs/operators'; 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 +42,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 +63,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; @@ -41,6 +72,14 @@ export class NewCopilotSurveyComponent implements OnInit { id: number; surveys: Survey[] = []; orgFromApp: string = ''; + hasQueryParams = false; + + // Use a subject to trigger searches + private searchTerms = new Subject(); + // Use BehaviorSubject for loading state + isLoading$ = new BehaviorSubject(false); + // Results observable + filteredMembers$: Observable; constructor( private fb: FormBuilder, @@ -54,8 +93,7 @@ export class NewCopilotSurveyComponent implements OnInit { 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 +102,79 @@ 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']); + + // 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']); + } + + if (params['repo']) { + this.surveyForm.get('repo')?.setValue(params['repo']); + } + + if (params['prno']) { + this.surveyForm.get('prNumber')?.setValue(params['prno']); + } + + // Handle GitHub URL parsing + if (params['url'] && params['url'].includes('github.com')) { + const { org, repo, prNumber } = this.parseGitHubPRUrl(params['url']); + if (!params['repo'] && repo) { + this.surveyForm.get('repo')?.setValue(repo); + } + if (!params['prno'] && prNumber) { + this.surveyForm.get('prNumber')?.setValue(prNumber); + } + } }); - // Subscribe to the installationsService to get the latest organization + // Get organization this.installationsService.currentInstallation.subscribe(installation => { this.orgFromApp = installation?.account?.login || ''; }); this.loadHistoricalReasons(); + // Handle Copilot usage toggle this.surveyForm.get('usedCopilot')?.valueChanges.subscribe((value) => { if (!value) { this.surveyForm.get('percentTimeSaved')?.setValue(0); @@ -86,16 +182,18 @@ 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 + } + loadHistoricalReasons() { this.copilotSurveyService.getAllSurveys({ reasonLength: 20, org: this.orgFromApp }).subscribe((surveys: Survey[]) => { this.surveys = surveys; - } - ); + }); } addKudos(survey: Survey) { @@ -125,29 +223,81 @@ 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 || '' + }; + + console.log('Submitting survey:', survey); + + 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 +306,104 @@ 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; + console.log('Member selected:', selectedMember); // Optional: for debugging + + // 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; + + console.log('onUserIdBlur called with value:', userId); // Optional: for debugging + + // Skip validation if the value is already a Member object (meaning an option was selected) + if (userId && typeof userId !== 'string' && userId.login) { + console.log('Value is a Member object, skipping validation'); // Optional: for debugging + 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; + + console.log('Validating userId:', userId); // Optional: for debugging + + // 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; + } + + // Only validate if the value is a string (user typed it in and didn't select an option) + if (typeof userId === 'string') { + console.log('Validating string value:', userId); // Optional: for debugging + + // Set loading state + this.isLoading$.next(true); + + // Call validation method with exact=true for case-insensitive matching + 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); + + console.log('User validated and updated to correct case:', result.login); // Optional: for debugging + } 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 }); + } + } + }); + } + } } 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..cf3cb389 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 @@ -87,11 +87,17 @@ export class CopilotValueModelingComponent implements OnInit { takeUntil(this._destroy$.asObservable()) ).subscribe(installation => { try { + //print targets to console after getting them + //console.log('Current installation:', installation); + // Fetch targets from the service + // and assign them to the data sources this.targetsService.getTargets().subscribe(targets => { this.orgDataSource = this.transformTargets(targets.org); this.userDataSource = this.transformTargets(targets.user); this.impactDataSource = this.transformTargets(targets.impact); }); + //print targets to console + console.log('Targets:', this.orgDataSource, this.userDataSource, this.impactDataSource); } catch (error) { console.error('Error during initialization:', error); } 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..04a7d66c 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 @@ -76,6 +76,7 @@ export class DailyActivityChartComponent implements OnInit, OnChanges { text: 'Daily Activity Per Avg User' }, min: 0, + maxPadding: 1.7, plotBands: [{ from: 500, to: 750, diff --git a/frontend/src/app/services/api/members.service.ts b/frontend/src/app/services/api/members.service.ts index 9b1bdc70..9d7fa231 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, map, Observable, tap } from 'rxjs'; import { throwError } from 'rxjs'; export interface Member { @@ -45,7 +45,32 @@ export class MembersService { }); } - getMemberByLogin(login: string) { - return this.http.get(`${this.apiUrl}/${login}`); + getMemberByLogin(login: string, exact: boolean = true) { + return this.http.get( + `${this.apiUrl}/${login}`, + { params: { exact: String(exact) } } + ).pipe( + catchError(error => { + return throwError(() => error); + }) + ); + } + + searchMembersByLogin(query: string): Observable { + console.log(`MembersService: Searching with query "${query}"`); + + // Use the correct API endpoint that works + const url = `${serverUrl}/api/members/search`; + console.log(`MembersService: Request URL: ${url}`); + + return this.http.get(url, { + params: { query } + }).pipe( + tap(response => console.log('MembersService: Search response length:', response?.length)), + catchError(error => { + console.error('MembersService: Search error:', error); + return throwError(() => error); + }) + ); } } 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/highcharts.service.ts b/frontend/src/app/services/highcharts.service.ts index 7b18514c..21f82d03 100644 --- a/frontend/src/app/services/highcharts.service.ts +++ b/frontend/src/app/services/highcharts.service.ts @@ -582,18 +582,21 @@ export class HighchartsService { }; Object.entries(activity).forEach(([date, dateData]) => { + // Skip if totalActive is undefined or 0 + if (!dateData.totalActive) return; + const currentMetrics = metrics.find(m => m.date.startsWith(date.slice(0, 10))); if (currentMetrics?.copilot_ide_code_completions) { (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 / dateData.totalActive), raw: date }); if (dailyActiveIdeAcceptsSeries && dailyActiveIdeAcceptsSeries.data) { dailyActiveIdeAcceptsSeries.data.push({ x: new Date(date).getTime(), - y: (currentMetrics.copilot_ide_code_completions.total_code_acceptances / (dateData.totalActive || 1)), + y: (currentMetrics.copilot_ide_code_completions.total_code_acceptances / dateData.totalActive), raw: date }); } @@ -601,21 +604,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 / dateData.totalActive), 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 / dateData.totalActive), 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 / dateData.totalActive), raw: date }); } @@ -649,7 +652,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; }