From 6079e6e34b3162987fa755931d45ce1abba1a0e1 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 1 May 2024 00:52:16 -0400 Subject: [PATCH] a giant ball of work --- app/controllers/client.js | 78 ++++++++---------- app/controllers/lib/populators.js | 73 +++++++++++++++++ app/controllers/manager.js | 43 ++++++++++ app/controllers/report.js | 6 ++ app/controllers/task.js | 35 ++------ app/models/client-project.js | 2 +- app/models/client.js | 7 ++ app/models/task-session.js | 2 +- app/services/client.js | 120 +++++++++++++++++++++++++++- app/services/report.js | 57 +++++++++++++ app/services/task.js | 13 ++- app/views/client/create.pug | 22 ----- app/views/client/editor.pug | 44 ++++++++++ app/views/client/project/editor.pug | 31 +++---- app/views/client/view.pug | 6 +- app/views/home.pug | 5 +- app/views/manager/dashboard.pug | 12 +++ app/views/report/dashboard.pug | 34 +++++++- app/views/task/view.pug | 18 +++-- app/workers/tracker-monitor.js | 103 ++++++++++++++++++++++++ client/js/time-tracker-client.js | 111 +++++++++++++++++-------- config/limiter.js | 13 ++- 22 files changed, 666 insertions(+), 169 deletions(-) create mode 100644 app/controllers/lib/populators.js create mode 100644 app/controllers/manager.js delete mode 100644 app/views/client/create.pug create mode 100644 app/views/client/editor.pug create mode 100644 app/views/manager/dashboard.pug create mode 100644 app/workers/tracker-monitor.js diff --git a/app/controllers/client.js b/app/controllers/client.js index a05cc65..a24b33b 100644 --- a/app/controllers/client.js +++ b/app/controllers/client.js @@ -7,6 +7,7 @@ import express from 'express'; import { SiteController, SiteError } from '../../lib/site-lib.js'; +import { populateClientId, populateProjectId } from './lib/populators.js'; export default class ClientController extends SiteController { @@ -44,8 +45,8 @@ export default class ClientController extends SiteController { const router = express.Router(); dtp.app.use('/client', authCheck, router); - router.param('clientId', this.populateClientId.bind(this)); - router.param('projectId', this.populateProjectId.bind(this)); + router.param('clientId', populateClientId(this)); + router.param('projectId', populateProjectId(this)); router.post( '/:clientId/project/:projectId', @@ -61,10 +62,17 @@ export default class ClientController extends SiteController { this.postProjectCreate.bind(this), ); + router.post( + '/:clientId', + limiterService.create(limiterConfig.postClientUpdate), + checkClientOwnership, + this.postClientUpdate.bind(this), + ); + router.post( '/', - limiterService.create(limiterConfig.postCreateClient), - this.postCreateClient.bind(this), + limiterService.create(limiterConfig.postClientCreate), + this.postClientCreate.bind(this), ); router.get( @@ -90,8 +98,15 @@ export default class ClientController extends SiteController { router.get( '/create', - limiterService.create(limiterConfig.getClientCreate), - this.getClientCreate.bind(this), + limiterService.create(limiterConfig.getClientEditor), + this.getClientEditor.bind(this), + ); + + router.get( + '/:clientId/edit', + limiterService.create(limiterConfig.getClientEditor), + checkClientOwnership, + this.getClientEditor.bind(this), ); router.get( @@ -110,40 +125,6 @@ export default class ClientController extends SiteController { return router; } - async populateClientId (req, res, next, clientId) { - const { client: clientService } = this.dtp.services; - try { - res.locals.client = await clientService.getClientById(clientId); - if (!res.locals.client) { - throw new SiteError(404, 'Client not found'); - } - if (!res.locals.client.user._id.equals(req.user._id)) { - throw new SiteError(401, 'This is not your client'); - } - return next(); - } catch (error) { - this.log.error('failed to populate client', { error }); - return next(error); - } - } - - async populateProjectId (req, res, next, projectId) { - const { client: clientService } = this.dtp.services; - try { - res.locals.project = await clientService.getProjectById(projectId); - if (!res.locals.project) { - throw new SiteError(404, 'Project not found'); - } - if (!res.locals.project.user._id.equals(req.user._id)) { - throw new SiteError(401, 'This is not your project'); - } - return next(); - } catch (error) { - this.log.error('failed to populate project', { error }); - return next(error); - } - } - async postProjectUpdate (req, res, next) { const { client: clientService } = this.dtp.services; try { @@ -166,7 +147,18 @@ export default class ClientController extends SiteController { } } - async postCreateClient (req, res, next) { + async postClientUpdate (req, res, next) { + const { client: clientService } = this.dtp.services; + try { + await clientService.updateClient(res.locals.client, req.body); + res.redirect(`/client/${res.locals.client._id}`); + } catch (error) { + this.log.error('failed to create client', { error }); + return next(error); + } + } + + async postClientCreate (req, res, next) { const { client: clientService } = this.dtp.services; try { res.locals.client = await clientService.createClient(req.user, req.body); @@ -196,8 +188,8 @@ export default class ClientController extends SiteController { } } - async getClientCreate (req, res) { - res.render('client/create'); + async getClientEditor (req, res) { + res.render('client/editor'); } async getClientView (req, res, next) { diff --git a/app/controllers/lib/populators.js b/app/controllers/lib/populators.js new file mode 100644 index 0000000..58d95ca --- /dev/null +++ b/app/controllers/lib/populators.js @@ -0,0 +1,73 @@ +'use strict'; + +import { SiteError } from '../../../lib/site-lib.js'; + +export function populateClientId (controller) { + return async function (req, res, next, clientId) { + const { client: clientService } = controller.dtp.services; + try { + res.locals.client = await clientService.getClientById(clientId); + if (!res.locals.client) { + throw new SiteError(404, 'Client not found'); + } + if (!res.locals.client.user._id.equals(req.user._id)) { + throw new SiteError(401, 'This is not your client'); + } + return next(); + } catch (error) { + controller.log.error('failed to populate client', { error }); + return next(error); + } + }; +} + +export function populateProjectId (controller) { + return async function (req, res, next, projectId) { + const { client: clientService } = controller.dtp.services; + try { + res.locals.project = await clientService.getProjectById(projectId); + if (!res.locals.project) { + throw new SiteError(404, 'Project not found'); + } + if (!res.locals.project.user._id.equals(req.user._id)) { + throw new SiteError(401, 'This is not your project'); + } + return next(); + } catch (error) { + controller.log.error('failed to populate project', { error }); + return next(error); + } + }; +} + +export function populateTaskId (controller) { + return async function (req, res, next, taskId) { + const { task: taskService } = controller.dtp.services; + try { + res.locals.task = await taskService.getTaskById(taskId); + if (!res.locals.task) { + throw new SiteError(404, 'Task not found'); + } + return next(); + } catch (error) { + controller.log.error('failed to populate taskId', { taskId, error }); + return next(error); + } + }; +} + +export function populateSessionId (controller) { + return async function (req, res, next, sessionId) { + const { task: taskService } = controller.dtp.services; + try { + res.locals.session = await taskService.getTaskSessionById(sessionId); + if (!res.locals.session) { + throw new SiteError(404, 'Task session not found'); + } + return next(); + } catch (error) { + controller.log.error('failed to populate sessionId', { sessionId, error }); + return next(error); + } + }; +} \ No newline at end of file diff --git a/app/controllers/manager.js b/app/controllers/manager.js new file mode 100644 index 0000000..a9bf7c6 --- /dev/null +++ b/app/controllers/manager.js @@ -0,0 +1,43 @@ +// manager.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import express from 'express'; + +import { SiteController } from '../../lib/site-lib.js'; +import { populateClientId, populateProjectId } from './lib/populators.js'; + +export default class ManagerController extends SiteController { + + static get name ( ) { return 'ManagerController'; } + static get slug ( ) { return 'manager'; } + + constructor (dtp) { + super(dtp, ManagerController.slug); + } + + async start ( ) { + const { dtp } = this; + const { + // limiter: limiterService, + session: sessionService, + } = dtp.services; + + // const limiterConfig = limiterService.config.manager; + const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); + + const router = express.Router(); + dtp.app.use('/manager', authCheck, router); + + router.param('clientId', populateClientId(this)); + router.param('projectId', populateProjectId(this)); + + router.get('/', this.getDashboard.bind(this)); + } + + async getDashboard (req, res) { + res.render('manager/dashboard'); + } +} \ No newline at end of file diff --git a/app/controllers/report.js b/app/controllers/report.js index 96819b3..77a31f8 100644 --- a/app/controllers/report.js +++ b/app/controllers/report.js @@ -36,7 +36,13 @@ export default class ReportController extends SiteController { async getDashboard (req, res, next) { const { report: reportService } = this.dtp.services; try { + res.locals.pageTitle = 'Weekly Summary Report'; + res.locals.pageDescription = 'A breakdown of project and contractor performance for the current week.'; + + res.locals.weekStartDate = reportService.startOfWeek(); res.locals.weeklyEarnings = await reportService.getWeeklyEarnings(req.user); + res.locals.dailyTimeWorked = await reportService.getDailyHoursWorkedForUser(req.user); + res.render('report/dashboard'); } catch (error) { this.log.error('failed to present report dashboard', { error }); diff --git a/app/controllers/task.js b/app/controllers/task.js index d944e97..b2abeae 100644 --- a/app/controllers/task.js +++ b/app/controllers/task.js @@ -7,6 +7,7 @@ import express from 'express'; import { SiteController, SiteError } from '../../lib/site-lib.js'; +import { populateTaskId, populateSessionId } from './lib/populators.js'; export default class TaskController extends SiteController { @@ -56,8 +57,8 @@ export default class TaskController extends SiteController { return next(); } - router.param('taskId', this.populateTaskId.bind(this)); - router.param('sessionId', this.populateSessionId.bind(this)); + router.param('taskId', populateTaskId(this)); + router.param('sessionId', populateSessionId(this)); router.post( '/:taskId/session/start', @@ -114,32 +115,6 @@ export default class TaskController extends SiteController { ); } - async populateTaskId (req, res, next, taskId) { - const { task: taskService } = this.dtp.services; - try { - res.locals.task = await taskService.getTaskById(taskId); - if (!res.locals.task) { - throw new SiteError(404, 'Task not found'); - } - return next(); - } catch (error) { - return next(error); - } - } - - async populateSessionId (req, res, next, sessionId) { - const { task: taskService } = this.dtp.services; - try { - res.locals.session = await taskService.getTaskSessionById(sessionId); - if (!res.locals.session) { - throw new SiteError(404, 'Task session not found'); - } - return next(); - } catch (error) { - return next(error); - } - } - async postStartTaskSession (req, res) { const { task: taskService } = this.dtp.services; try { @@ -223,7 +198,7 @@ export default class TaskController extends SiteController { } async getTaskView (req, res, next) { - const { task: taskService } = this.dtp.services; + const { report: reportService, task: taskService } = this.dtp.services; try { res.locals.pagination = this.getPaginationParameters(req, 50); res.locals.sessions = await taskService.getSessionsForTask( @@ -231,6 +206,8 @@ export default class TaskController extends SiteController { res.locals.pagination, ); + res.locals.weekStartDate = reportService.startOfWeek(); + res.render('task/view'); } catch (error) { this.log.error('failed to present the Task view', { error }); diff --git a/app/models/client-project.js b/app/models/client-project.js index 48b9dc4..7d29733 100644 --- a/app/models/client-project.js +++ b/app/models/client-project.js @@ -11,10 +11,10 @@ const ClientProjectSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: 1 }, user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, + managers: { type: [Schema.ObjectId], ref: 'User' }, name: { type: String, required: true }, description: { type: String }, hourlyRate: { type: Number, required: true }, - hoursLimit: { type: Number, default: 0, required: true }, }); export default mongoose.model('ClientProject', ClientProjectSchema); \ No newline at end of file diff --git a/app/models/client.js b/app/models/client.js index 9722be4..eb6e868 100644 --- a/app/models/client.js +++ b/app/models/client.js @@ -7,10 +7,17 @@ import mongoose from 'mongoose'; const Schema = mongoose.Schema; +const MAX_HOURS_PER_WEEK = 7 * 24; + const ClientSchema = new Schema({ user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, name: { type: String, required: true }, description: { type: String }, + hoursLimit: { type: Number, default: 0, max: MAX_HOURS_PER_WEEK, required: true }, + weeklyTotals: { + timeWorked: { type: Number, default: 0, required: true }, + billable: { type: Number, default: 0, required: true }, + }, }); export default mongoose.model('Client', ClientSchema); \ No newline at end of file diff --git a/app/models/task-session.js b/app/models/task-session.js index d304147..91ab932 100644 --- a/app/models/task-session.js +++ b/app/models/task-session.js @@ -7,7 +7,7 @@ import mongoose from 'mongoose'; const Schema = mongoose.Schema; -const STATUS_LIST = ['active', 'finished']; +const STATUS_LIST = ['active', 'finished', 'expired']; const ScreenshotSchema = new Schema({ created: { type: Date, required: true, default: Date.now, index: 1 }, diff --git a/app/services/client.js b/app/services/client.js index 849c5d7..f95162c 100644 --- a/app/services/client.js +++ b/app/services/client.js @@ -50,12 +50,46 @@ export default class ClientService extends SiteService { if (clientDefinition.description && clientDefinition.description.length > 0) { client.description = textService.filter(clientDefinition.description); } + client.hoursLimit = clientDefinition.hoursLimit; await client.save(); return client.toObject(); } + async updateClient (client, clientDefinition) { + const { text: textService } = this.dtp.services; + const update = { $set: { }, $unset: { } }; + + let changed = false; + + if (clientDefinition.name !== client.name) { + update.$set.name = textService.filter(clientDefinition.name); + changed = true; + } + + if (clientDefinition.description && (clientDefinition.description.length > 0)) { + if (clientDefinition.description !== client.description) { + update.$set.description = textService.filter(clientDefinition.description); + changed = true; + } + } else { + update.$unset.description = 1; + changed = true; + } + + if (clientDefinition.hoursLimit !== client.hoursLimit) { + update.$set.hoursLimit = clientDefinition.hoursLimit; + changed = true; + } + + if (!changed) { + return; + } + + await Client.updateOne({ _id: client._id }, update); + } + async getClientById (clientId) { const client = Client .findOne({ _id: clientId }) @@ -86,7 +120,6 @@ export default class ClientService extends SiteService { } project.hourlyRate = projectDefinition.hourlyRate; - project.hoursLimit = projectDefinition.hoursLimit; await project.save(); @@ -96,16 +129,25 @@ export default class ClientService extends SiteService { async updateProject (project, projectDefinition) { const { text: textService } = this.dtp.services; const update = { $set: { }, $unset: { } }; + let changed = false; if (projectDefinition.name !== project.name) { update.$set.name = textService.filter(projectDefinition.name); + changed = true; } if (projectDefinition.description !== project.description) { update.$set.description = textService.filter(projectDefinition.description); + changed = true; + } + + if (projectDefinition.hourlyRate !== project.hourlyRate) { + update.$set.hourlyRate = projectDefinition.hourlyRate; + changed = true; } - update.$set.hourlyRate = projectDefinition.hourlyRate; - update.$set.hoursLimit = projectDefinition.hoursLimit; + if (!changed) { + return; + } await ClientProject.updateOne({ _id: project._id }, update); } @@ -135,4 +177,76 @@ export default class ClientService extends SiteService { .lean(); return projects; } + + async addTimeWorked (client, duration) { + const { task: taskService } = this.dtp.services; + + const durationLimit = client.hoursLimit * 3600; + if ((client.weeklyTotals.timeWorked + duration) > durationLimit) { + this.log.alert('clamping time worked to weekly hours limit', { + user: { + _id: client.user._id, + username: client.user.username, + }, + client: { + _id: client._id, + hoursLimit: client.hoursLimit, + }, + }); + await Client.updateOne( + { _id: client._id }, + { + $set: { 'weeklyTotals.timeWorked': durationLimit }, + }, + ); + + this.log.alert('Ending work sessions for user due to hours limit reached', { + user: { + _id: client.user._id, + username: client.user.username, + }, + client: { + _id: client._id, + hoursLimit: client.hoursLimit, + }, + }); + await taskService.closeTaskSessionForUser(client.user); + + const displayList = this.createDisplayList('session-control'); + displayList.showNotification( + 'Hours limit reached. The task work session has been closed.', + 'danger', + 'bottom-center', + 5000, + ); + + this.dtp.emitter + .to(client.user._id.toString()) + .emit('session-control', { + displayList, + cmd: 'end-session', + }); + + return; + } + + this.log.info('adding time worked to client weekly totals', { + user: { + _id: client.user._id, + username: client.user.username, + }, + client: { + _id: client._id, + hoursLimit: client.hoursLimit, + currentHours: client.weeklyTotals.timeWorked / 3600, + }, + hours: duration / 3600, + }); + await Client.updateOne( + { _id: client._id }, + { + $inc: { 'weeklyTotals.timeWorked': duration }, + }, + ); + } } \ No newline at end of file diff --git a/app/services/report.js b/app/services/report.js index f980456..e94f329 100644 --- a/app/services/report.js +++ b/app/services/report.js @@ -75,6 +75,63 @@ export default class ReportService extends SiteService { return response; } + async getDailyHoursWorkedForUser (user) { + const NOW = new Date(); + const dateStart = this.startOfWeek(NOW); + const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); + + const response = await TaskSession.aggregate([ + { + $match: { + $and: [ + { user: user._id }, + { finished: { $gt: dateStart } }, + { created: { $lt: dateEnd } }, + ], + } + }, + { + $group: { + _id: { + year: { $year: '$created' }, + month: { $month: '$created' }, + day: { $dayOfMonth: '$created' }, + }, + workSessionCount: { $sum: 1 }, + timeWorked: { $sum: '$duration' }, + }, + }, + { + $project: { + _id: false, + date: { + $dateFromParts: { + year: '$_id.year', + month: '$_id.month', + day: '$_id.day', + }, + }, + workSessionCount: '$workSessionCount', + hoursWorked: { $divide: ['$timeWorked', 3600] }, + }, + }, + ]); + + if (response.length < 7) { + let currentDay = dayjs(dateStart).add(response.length, 'day'); + while (response.length < 7) { + response.push({ + date: currentDay, + workSessionCount: 0, + hoursWorked: 0, + }); + currentDay = dayjs(currentDay).add(1, 'day'); + } + } + + return response; + } + startOfWeek (date) { date = date || new Date(); date.setHours(0,0,0,0); diff --git a/app/services/task.js b/app/services/task.js index d764caa..0f43e2d 100644 --- a/app/services/task.js +++ b/app/services/task.js @@ -183,7 +183,7 @@ export default class TaskService extends SiteService { throw new SiteError(401, "Can't start new session with a currently active session."); } - const session = new TaskSession(); + let session = new TaskSession(); session.created = NOW; session.lastUpdated = NOW; session.hourlyRate = task.project.hourlyRate; @@ -194,12 +194,16 @@ export default class TaskService extends SiteService { session.status = 'active'; await session.save(); + session = await TaskSession.populate(session, this.populateTaskSession); this.log.info('task session created', { user: { _id: task.user._id, username: task.user.username, }, + session: { + _id: session._id, + }, }); return session.toObject(); @@ -222,7 +226,8 @@ export default class TaskService extends SiteService { ); } - async closeTaskSession (session) { + async closeTaskSession (session, status = 'finished') { + const { client: clientService } = this.dtp.services; const NOW = new Date(); if (session.status !== 'active') { @@ -235,7 +240,7 @@ export default class TaskService extends SiteService { { $set: { finished: NOW, - status: 'finished', + status, duration, }, }, @@ -248,6 +253,8 @@ export default class TaskService extends SiteService { }, ); + await clientService.addTimeWorked(session.client, duration); + this.log.info('task session closed', { user: { _id: session.user._id, diff --git a/app/views/client/create.pug b/app/views/client/create.pug deleted file mode 100644 index 891739a..0000000 --- a/app/views/client/create.pug +++ /dev/null @@ -1,22 +0,0 @@ -extends ../layout/main -block view-content - - section.uk-section.uk-section-default.uk-section-small - .uk-container - - .uk-margin - form(method="POST", action="/client").uk-form - .uk-card.uk-card-default.uk-card-small - .uk-card-header - h1.uk-card-title New Client - - .uk-card-body - .uk-margin - label(for="name").uk-form-label Client name - input(id="name", name="name", type="text", placeholder="Enter client name").uk-input - .uk-margin - label(for="description").uk-form-label Client description - textarea(id="name", name="description", rows=4, placeholder="Enter client description").uk-textarea.uk-resize-vertical - - .uk-card-footer.uk-flex.uk-flex-right - button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Client \ No newline at end of file diff --git a/app/views/client/editor.pug b/app/views/client/editor.pug new file mode 100644 index 0000000..af02e0c --- /dev/null +++ b/app/views/client/editor.pug @@ -0,0 +1,44 @@ +extends ../layout/main +block view-content + + section.uk-section.uk-section-default.uk-section-small + .uk-container + + .uk-margin + - var actionUrl = !!client ? `/client/${client._id}` : "/client"; + form(method="POST", action= actionUrl).uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title #[span= !!client ? "Edit" : "New"] Client + + .uk-card-body + .uk-margin + label(for="name").uk-form-label Client name + input( + id="name", + name="name", + type="text", + value= !!client ? client.name : undefined, + placeholder="Enter client name", + ).uk-input + .uk-margin + label(for="description").uk-form-label Client description + textarea( + id="name", + name="description", + rows=4, + placeholder="Enter client description", + ).uk-textarea.uk-resize-vertical= !!client ? client.description : undefined + .uk-margin + label(for="hours-limit").uk-form-label Max. hours per week + input( + id="hours-limit", + name="hoursLimit", + type="number", + value= !!client ? (client.hoursLimit || 40) : 40, + placeholder="Enter max. hours/week (0=unlimited)", + ).uk-input + .uk-text-small.uk-text-muted Use zero or 168 (7 x 24) for unlimited hours per week. + + .uk-card-footer.uk-flex.uk-flex-right + button(type="submit").uk-button.uk-button-default.uk-border-rounded #[span= !!client ? "Update" : "Create"] Client \ No newline at end of file diff --git a/app/views/client/project/editor.pug b/app/views/client/project/editor.pug index 6ecca2a..9fe99fc 100644 --- a/app/views/client/project/editor.pug +++ b/app/views/client/project/editor.pug @@ -23,7 +23,7 @@ block view-content name="name", type="text", value= !!project ? project.name : undefined, - placeholder="Enter client name", + placeholder="Enter project name", ).uk-input .uk-margin label(for="description").uk-form-label Project description @@ -31,28 +31,17 @@ block view-content id="name", name="description", rows=4, - placeholder="Enter client description" + placeholder="Enter project description" ).uk-textarea.uk-resize-vertical= !!project ? project.description : "" .uk-margin - div(uk-grid) - .uk-width-1-2 - label(for="hourly-rate").uk-form-label Hourly rate - input( - id="hourly-rate", - name="hourlyRate", - type="number", - value= !!project ? project.hourlyRate : undefined, - placeholder="Enter hourly rate", - ).uk-input - .uk-width-1-2 - label(for="hours-limit").uk-form-label Max. hours per week - input( - id="hours-limit", - name="hoursLimit", - type="number", - value= value= !!project ? project.hoursLimit : 40, - placeholder="Enter max. hours/week", - ).uk-input + label(for="hourly-rate").uk-form-label Hourly rate + input( + id="hourly-rate", + name="hourlyRate", + type="number", + value= !!project ? project.hourlyRate : undefined, + placeholder="Enter hourly rate", + ).uk-input .uk-card-footer.uk-flex.uk-flex-right button(type="submit").uk-button.uk-button-default.uk-border-rounded #[span= !!project ? "Update" : "Create"] Project \ No newline at end of file diff --git a/app/views/client/view.pug b/app/views/client/view.pug index 7904c65..ef8b579 100644 --- a/app/views/client/view.pug +++ b/app/views/client/view.pug @@ -5,11 +5,15 @@ block view-content .uk-container .uk-margin-medium - div(uk-grid) + div(uk-grid).uk-grid-small .uk-width-expand h1.uk-margin-remove= client.name if client.description div= client.description + .uk-width-auto + a(href=`/client/${client._id}/edit`).uk-button.uk-button-default.uk-button-small + i.fa-solid.fa-cog + span.uk-margin-small-left Edit Client .uk-width-auto a(href=`/client/${client._id}/project/create`).uk-button.uk-button-default.uk-button-small i.fa-solid.fa-plus diff --git a/app/views/home.pug b/app/views/home.pug index cc73043..ca69af4 100644 --- a/app/views/home.pug +++ b/app/views/home.pug @@ -6,7 +6,10 @@ block view-content section.uk-section.uk-section-muted.uk-section-small .uk-container - +renderWeeklySummaryReport(weeklyEarnings) + if weeklyEarnings.length > 0 + +renderWeeklySummaryReport(weeklyEarnings) + else + div No time worked this week. section.uk-section.uk-section-default.uk-section-small .uk-container diff --git a/app/views/manager/dashboard.pug b/app/views/manager/dashboard.pug new file mode 100644 index 0000000..152726e --- /dev/null +++ b/app/views/manager/dashboard.pug @@ -0,0 +1,12 @@ +extends ../layout/main +block view-content + + include ../user/components/profile-picture + + section.uk-section.uk-section-default.uk-section-small + .uk-container + + h1 Projects You Manage + if Array.isArray(projects) && (projects.length > 0) + else + div You don't manage any projects at this time. \ No newline at end of file diff --git a/app/views/report/dashboard.pug b/app/views/report/dashboard.pug index 6725163..b147e6c 100644 --- a/app/views/report/dashboard.pug +++ b/app/views/report/dashboard.pug @@ -5,4 +5,36 @@ block view-content section.uk-section.uk-section-default.uk-section .uk-container - +renderWeeklySummaryReport(weeklyEarnings) \ No newline at end of file + h1 Week of #{dayjs(weekStartDate).format('MMMM DD')} + +renderWeeklySummaryReport(weeklyEarnings) + + .uk-margin-medium + h2 Daily Hours + table.uk-table.uk-table-small.uk-table-divider + thead + tr + th.uk-table-expand Day of Week + th.uk-text-nowrap.uk-text-right.uk-table-shrink Session Count + th.uk-text-nowrap.uk-text-right.uk-table-shrink Hours Worked + th.uk-text-nowrap.uk-text-right.uk-table-shrink Time Worked + tbody + - + var totalHoursWorked = 0; + var totalSessionCount = 0; + + each day in dailyTimeWorked + - + totalHoursWorked += day.hoursWorked; + totalSessionCount += day.workSessionCount; + + tr + td.uk-table-expand= dayjs(day.date).format('dddd') + td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.workSessionCount > 0) ? formatCount(day.workSessionCount) : '---' + td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.hoursWorked > 0) ? numeral(day.hoursWorked).format('0.00') : '---' + td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.hoursWorked > 0) ? numeral(day.hoursWorked).format('0:00:00') : '---' + tfoot + tr + td.uk-table-expand TOTALS + td.uk-text-right.uk-text-nowrap.uk-table-shrink= formatCount(totalSessionCount) + td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalHoursWorked).format('0.00') + td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalHoursWorked * 3600).format('0:00:00') \ No newline at end of file diff --git a/app/views/task/view.pug b/app/views/task/view.pug index 33668c1..47c2953 100644 --- a/app/views/task/view.pug +++ b/app/views/task/view.pug @@ -40,19 +40,27 @@ block view-content label(for="active-toggle") span.sr-only Active #current-session-duration.uk-text-small.no-select= numeral(0).format('HH:MM:SS') - #current-session-billable.uk-text-small.no-select $0.00 + + - var timeRemaining = task.client.hoursLimit - (task.client.weeklyTotals.timeWorked / 3600); + .uk-text-small.no-select avail: #[span#time-remaining= numeral(timeRemaining).format('0,0.00')] .uk-margin-medium - h3 Work Sessions + + .uk-margin + h3(style="line-height: 1em;").uk-padding-remove.uk-margin-remove Work Sessions + small.uk-text-muted Week of #{dayjs(weekStartDate).format('MMMM DD')} + if Array.isArray(sessions) && (sessions.length > 0) ul.uk-list.uk-list-divider each session in sessions li - a(href=`/task/${task._id}/session/${session._id}`, target="timetrackview").uk-link-reset.uk-display-block + a(href=`/task/${task._id}/session/${session._id}`, + onclick="return dtp.app.performSessionNavigation(event);", + ).uk-link-reset.uk-display-block div(uk-grid) - .uk-width-expand= dayjs(session.created).format('MMM DD, YYYY') - .uk-width-auto= numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,00.00') + .uk-width-expand= dayjs(session.created).format('dddd [at] h:mm a') .uk-width-auto= numeral(session.duration).format('HH:MM:SS') + .uk-width-auto= numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,00.00') else div No work sessions diff --git a/app/workers/tracker-monitor.js b/app/workers/tracker-monitor.js new file mode 100644 index 0000000..debc170 --- /dev/null +++ b/app/workers/tracker-monitor.js @@ -0,0 +1,103 @@ +// tracker-monitor.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import 'dotenv/config'; + +import path, { dirname } from 'path'; +import dayjs from 'dayjs'; + +import { SiteRuntime } from '../../lib/site-lib.js'; + +import { CronJob } from 'cron'; +const CRON_TIMEZONE = 'America/New_York'; + +class TrackerMonitorWorker extends SiteRuntime { + + static get name ( ) { return 'TrackerMonitorWorker'; } + static get slug ( ) { return 'trackerMonitor'; } + + constructor (rootPath) { + super(TrackerMonitorWorker, rootPath); + } + + async start ( ) { + await super.start(); + + const mongoose = await import('mongoose'); + this.TaskSession = mongoose.model('TaskSession'); + + this.viewModel = { }; + await this.populateViewModel(this.viewModel); + + /* + * Cron jobs + */ + + const sessionExpireSchedule = '* */5 * * * *'; // Every 5 minutes + this.cronJob = new CronJob( + sessionExpireSchedule, + this.expireTaskSessions.bind(this), + null, + true, + CRON_TIMEZONE, + ); + + /* + * Bull Queue job processors + */ + + // this.log.info('registering queue job processor', { config: this.config.jobQueues.links }); + // this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links); + // this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this)); + } + + async shutdown ( ) { + this.log.alert('ChatLinksWorker shutting down'); + await super.shutdown(); + } + + async expireTaskSessions ( ) { + const { task: taskService } = this.services; + + const NOW = new Date(); + const oldestDate = dayjs(NOW).subtract(10, 'minute'); + + await this.TaskSession + .find({ + $and: [ + { status: 'active' }, + { lastUpdated: { $lt: oldestDate } }, + ], + }) + .cursor() + .eachAsync(async (session) => { + this.log.info('expiring defunct task work session', { + session: { + _id: session._id, + created: session.created, + lastUpdated: session.lastUpdated, + }, + }); + taskService.closeTaskSession(session, 'expired'); + }); + } +} + +(async ( ) => { + + try { + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + + const worker = new TrackerMonitorWorker(path.resolve(__dirname, '..', '..')); + await worker.start(); + + } catch (error) { + console.error('failed to start tracker monitor worker', { error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/client/js/time-tracker-client.js b/client/js/time-tracker-client.js index 1c660b1..6b8d1d0 100644 --- a/client/js/time-tracker-client.js +++ b/client/js/time-tracker-client.js @@ -19,6 +19,8 @@ dayjs.extend(dayjsRelativeTime); export class TimeTrackerApp extends DtpApp { + static get SCREENSHOT_INTERVAL ( ) { return 1000 * 60 * 10; } + static get SFX_TRACKER_START ( ) { return 'tracker-start'; } static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; } static get SFX_TRACKER_STOP ( ) { return 'tracker-stop'; } @@ -36,7 +38,7 @@ export class TimeTrackerApp extends DtpApp { this.currentSessionStartTime = null; this.currentSessionDuration = document.querySelector('#current-session-duration'); - this.currentSessionBillable = document.querySelector('#current-session-billable'); + this.currentSessionTimeRemaining = document.querySelector('#time-remaining'); window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); window.addEventListener('focus', this.onWindowFocus.bind(this)); @@ -57,8 +59,8 @@ export class TimeTrackerApp extends DtpApp { await this.connect({ mode: 'User', - onSocketConnect: this.onChatSocketConnect.bind(this), - onSocketDisconnect: this.onChatSocketDisconnect.bind(this), + onSocketConnect: this.onTrackerSocketConnect.bind(this), + onSocketDisconnect: this.onTrackerSocketDisconnect.bind(this), }); } @@ -142,16 +144,17 @@ export class TimeTrackerApp extends DtpApp { this.dragFeedback.classList.remove('feedback-active'); } - async onChatSocketConnect (socket) { + async onTrackerSocketConnect (socket) { this.log.debug('onSocketConnect', 'attaching socket events'); socket.on('system-message', this.onSystemMessage.bind(this)); + socket.on('session-control', this.onSessionControl.bind(this)); if (dtp.task) { await this.socket.joinChannel(dtp.task._id, 'Task'); } } - async onChatSocketDisconnect (socket) { + async onTrackerSocketDisconnect (socket) { this.log.debug('onSocketDisconnect', 'detaching socket events'); socket.off('system-message', this.onSystemMessage.bind(this)); } @@ -162,6 +165,30 @@ export class TimeTrackerApp extends DtpApp { } } + async onSessionControl (message) { + const activityToggle = document.querySelector(''); + + switch (message.cmd) { + case 'end-session': + try { + await this.closeTaskSession(); + activityToggle.checked = false; + } catch (error) { + this.log.error('onSessionControl', 'failed to close task work session', { error }); + return; + } + break; + + default: + this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd }); + return; + } + + if (message.displayList) { + this.displayEngine.executeDisplayList(message.displayList); + } + } + async confirmNavigation (event) { const target = event.currentTarget || event.target; event.preventDefault(); @@ -171,10 +198,11 @@ export class TimeTrackerApp extends DtpApp { const hrefTarget = target.getAttribute('target'); const text = target.textContent; const whitelist = [ - 'digitaltelepresence.com', - 'www.digitaltelepresence.com', 'chat.digitaltelepresence.com', + 'digitaltelepresence.com', 'sites.digitaltelepresence.com', + 'tracker.digitaltelepresence.com', + 'www.digitaltelepresence.com', ]; try { const url = new URL(href); @@ -189,6 +217,22 @@ export class TimeTrackerApp extends DtpApp { return true; } + async performSessionNavigation (event) { + const target = event.currentTarget || event.target; + + event.preventDefault(); + event.stopPropagation(); + + const href = target.getAttribute('href'); + const hrefTarget = target.getAttribute('target'); + if (this.taskSession || (hrefTarget && (hrefTarget.length > 0))) { + return window.open(href, hrefTarget); + } + + window.location = href; + return true; + } + async generateOtpQR (canvas, keyURI) { QRCode.toCanvas(canvas, keyURI); } @@ -432,10 +476,10 @@ export class TimeTrackerApp extends DtpApp { throw new Error(json.message); } - this.taskSession = json.session; + this.taskSession = json.session; this.currentSessionStartTime = new Date(); - this.screenshotInterval = setInterval(this.captureScreenshot.bind(this), 1000 * 60 * 10); + this.screenshotInterval = setInterval(this.captureScreenshot.bind(this), TimeTrackerApp.SCREENSHOT_INTERVAL); this.sessionDisplayUpdateInterval = setInterval(this.updateSessionDisplay.bind(this), 250); this.currentSessionDuration.classList.add('uk-text-success'); @@ -475,18 +519,6 @@ export class TimeTrackerApp extends DtpApp { this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); this.capturePreview.srcObject = this.captureStream; this.capturePreview.play(); - - const tracks = this.captureStream.getVideoTracks(); - const constraints = tracks[0].getSettings(); - - this.log.info('startScreenCapture', 'creating capture canvas', { - width: constraints.width, - height: constraints.height, - }); - this.captureCanvas = document.createElement('canvas'); - this.captureCanvas.width = constraints.width; - this.captureCanvas.height = constraints.height; - this.captureContext = this.captureCanvas.getContext('2d'); } async stopScreenCapture ( ) { @@ -499,20 +531,18 @@ export class TimeTrackerApp extends DtpApp { this.captureStream.getTracks().forEach(track => track.stop()); delete this.captureStream; - - if (this.captureContext) { - delete this.captureContext; - } - if (this.captureCanvas) { - delete this.captureCanvas; - } } async updateSessionDisplay ( ) { const NOW = new Date(); const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second'); this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS'); - this.currentSessionBillable.textContent = numeral(this.taskSession.hourlyRate * (duration / 60 / 60)).format('$0,0.00'); + + const timeRemaining = + this.taskSession.client.hoursLimit - + ((this.taskSession.client.weeklyTotals.timeWorked + duration) / 3600) + ; + this.currentSessionTimeRemaining.textContent = numeral(timeRemaining).format('0,0.00'); } async captureScreenshot ( ) { @@ -520,19 +550,33 @@ export class TimeTrackerApp extends DtpApp { return; } try { + const tracks = this.captureStream.getVideoTracks(); + const constraints = tracks[0].getSettings(); + + this.log.info('startScreenCapture', 'creating capture canvas', { + width: constraints.width, + height: constraints.height, + }); + + const captureCanvas = document.createElement('canvas'); + captureCanvas.width = constraints.width; + captureCanvas.height = constraints.height; + + const captureContext = captureCanvas.getContext('2d'); + /* * Capture the current preview stream frame to the capture canvas */ - this.captureContext.drawImage( + captureContext.drawImage( this.capturePreview, 0, 0, - this.captureCanvas.width, - this.captureCanvas.height, + captureCanvas.width, + captureCanvas.height, ); /* * Generate a PNG Blob from the capture canvas */ - this.captureCanvas.toBlob( + captureCanvas.toBlob( async (blob) => { const formData = new FormData(); formData.append('image', blob, 'screenshot.png'); @@ -547,7 +591,6 @@ export class TimeTrackerApp extends DtpApp { this.log.info('captureScreenshot', 'screenshot posted to task session'); }, 'image/png', - 1.0, ); } catch (error) { this.log.error('captureScreenshot', 'failed to capture screenshot', { error }); diff --git a/config/limiter.js b/config/limiter.js index f3cdc2a..b5cf828 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -81,7 +81,12 @@ export default { expire: ONE_HOUR, message: "You are creating projects too quickly", }, - postCreateClient: { + postClientUpdate: { + total: 10, + expire: ONE_HOUR, + message: "You are updating clients too quickly", + }, + postClientCreate: { total: 10, expire: ONE_HOUR, message: "You are creating clients too quickly", @@ -101,10 +106,10 @@ export default { expire: ONE_HOUR, message: "You are viewing projects too quickly", }, - getClientCreate: { - total: 10, + getClientEditor: { + total: 100, expire: ONE_HOUR, - message: "You are creating clients too quickly", + message: "You are creating or editing clients too quickly", }, getClientView: { total: 250,