Browse Source

a giant ball of work

develop
Rob Colbert 12 months ago
parent
commit
6079e6e34b
  1. 78
      app/controllers/client.js
  2. 73
      app/controllers/lib/populators.js
  3. 43
      app/controllers/manager.js
  4. 6
      app/controllers/report.js
  5. 35
      app/controllers/task.js
  6. 2
      app/models/client-project.js
  7. 7
      app/models/client.js
  8. 2
      app/models/task-session.js
  9. 120
      app/services/client.js
  10. 57
      app/services/report.js
  11. 13
      app/services/task.js
  12. 22
      app/views/client/create.pug
  13. 44
      app/views/client/editor.pug
  14. 31
      app/views/client/project/editor.pug
  15. 6
      app/views/client/view.pug
  16. 5
      app/views/home.pug
  17. 12
      app/views/manager/dashboard.pug
  18. 34
      app/views/report/dashboard.pug
  19. 18
      app/views/task/view.pug
  20. 103
      app/workers/tracker-monitor.js
  21. 111
      client/js/time-tracker-client.js
  22. 13
      config/limiter.js

78
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) {

73
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);
}
};
}

43
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');
}
}

6
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 });

35
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 });

2
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);

7
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);

2
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 },

120
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 },
},
);
}
}

57
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);

13
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,

22
app/views/client/create.pug

@ -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

44
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

31
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

6
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

5
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

12
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.

34
app/views/report/dashboard.pug

@ -5,4 +5,36 @@ block view-content
section.uk-section.uk-section-default.uk-section
.uk-container
+renderWeeklySummaryReport(weeklyEarnings)
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')

18
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

103
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);
}
})();

111
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 });

13
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,

Loading…
Cancel
Save