Browse Source

converted to the new dtp-base

develop
Rob Colbert 12 months ago
parent
commit
230b7fbbd4
  1. 2
      LICENSE
  2. 6
      README.md
  3. 216
      app/controllers/client.js
  4. 12
      app/controllers/home.js
  5. 73
      app/controllers/lib/populators.js
  6. 43
      app/controllers/manager.js
  7. 52
      app/controllers/report.js
  8. 257
      app/controllers/task.js
  9. 20
      app/models/client-project.js
  10. 23
      app/models/client.js
  11. 5
      app/models/lib/constants.js
  12. 23
      app/models/lib/media.js
  13. 37
      app/models/task-session.js
  14. 23
      app/models/task.js
  15. 2
      app/models/user.js
  16. 4
      app/models/video.js
  17. 293
      app/services/client.js
  18. 141
      app/services/report.js
  19. 329
      app/services/task.js
  20. 9
      app/services/text.js
  21. 24
      app/views/client/dashboard.pug
  22. 54
      app/views/client/editor.pug
  23. 60
      app/views/client/project/editor.pug
  24. 39
      app/views/client/project/view.pug
  25. 32
      app/views/client/view.pug
  26. 7
      app/views/components/navbar.pug
  27. 39
      app/views/home.pug
  28. 2
      app/views/layout/main.pug
  29. 12
      app/views/manager/dashboard.pug
  30. 20
      app/views/report/components/weekly-summary.pug
  31. 41
      app/views/report/dashboard.pug
  32. 23
      app/views/task/components/grid.pug
  33. 11
      app/views/task/components/list.pug
  34. 55
      app/views/task/session/view.pug
  35. 102
      app/views/task/view.pug
  36. 4
      app/views/user/settings.pug
  37. 7
      app/views/welcome/home.pug
  38. 107
      app/workers/tracker-monitor.js
  39. BIN
      assets/icon/dtp-chat.app-icon.png
  40. BIN
      assets/icon/dtp-chat.app-icon/icon-114x114.png
  41. BIN
      assets/icon/dtp-chat.app-icon/icon-120x120.png
  42. BIN
      assets/icon/dtp-chat.app-icon/icon-144x144.png
  43. BIN
      assets/icon/dtp-chat.app-icon/icon-150x150.png
  44. BIN
      assets/icon/dtp-chat.app-icon/icon-152x152.png
  45. BIN
      assets/icon/dtp-chat.app-icon/icon-16x16.png
  46. BIN
      assets/icon/dtp-chat.app-icon/icon-180x180.png
  47. BIN
      assets/icon/dtp-chat.app-icon/icon-192x192.png
  48. BIN
      assets/icon/dtp-chat.app-icon/icon-256x256.png
  49. BIN
      assets/icon/dtp-chat.app-icon/icon-310x310.png
  50. BIN
      assets/icon/dtp-chat.app-icon/icon-32x32.png
  51. BIN
      assets/icon/dtp-chat.app-icon/icon-36x36.png
  52. BIN
      assets/icon/dtp-chat.app-icon/icon-384x384.png
  53. BIN
      assets/icon/dtp-chat.app-icon/icon-48x48.png
  54. BIN
      assets/icon/dtp-chat.app-icon/icon-512x512.png
  55. BIN
      assets/icon/dtp-chat.app-icon/icon-57x57.png
  56. BIN
      assets/icon/dtp-chat.app-icon/icon-60x60.png
  57. BIN
      assets/icon/dtp-chat.app-icon/icon-70x70.png
  58. BIN
      assets/icon/dtp-chat.app-icon/icon-72x72.png
  59. BIN
      assets/icon/dtp-chat.app-icon/icon-76x76.png
  60. BIN
      assets/icon/dtp-chat.app-icon/icon-96x96.png
  61. BIN
      client/img/app-icon.png
  62. BIN
      client/img/icon/icon-114x114.png
  63. BIN
      client/img/icon/icon-120x120.png
  64. BIN
      client/img/icon/icon-144x144.png
  65. BIN
      client/img/icon/icon-150x150.png
  66. BIN
      client/img/icon/icon-152x152.png
  67. BIN
      client/img/icon/icon-16x16.png
  68. BIN
      client/img/icon/icon-180x180.png
  69. BIN
      client/img/icon/icon-192x192.png
  70. BIN
      client/img/icon/icon-256x256.png
  71. BIN
      client/img/icon/icon-310x310.png
  72. BIN
      client/img/icon/icon-32x32.png
  73. BIN
      client/img/icon/icon-36x36.png
  74. BIN
      client/img/icon/icon-384x384.png
  75. BIN
      client/img/icon/icon-48x48.png
  76. BIN
      client/img/icon/icon-512x512.png
  77. BIN
      client/img/icon/icon-57x57.png
  78. BIN
      client/img/icon/icon-60x60.png
  79. BIN
      client/img/icon/icon-70x70.png
  80. BIN
      client/img/icon/icon-72x72.png
  81. BIN
      client/img/icon/icon-76x76.png
  82. BIN
      client/img/icon/icon-96x96.png
  83. BIN
      client/img/nav-icon.png
  84. 8
      client/js/base-audio.js
  85. 340
      client/js/base-client.js
  86. 4
      client/js/index.js
  87. 652
      client/js/time-tracker-client.js
  88. 0
      client/static/sfx/sample-sound.mp3
  89. BIN
      client/static/sfx/tracker-stop.mp3
  90. BIN
      client/static/sfx/tracker-update.mp3
  91. 107
      config/limiter.js
  92. 5
      config/reserved-names.js
  93. 35
      data/patches/client-stats.js
  94. 55
      dtp-base-cli.js
  95. 2
      dtp-base.js
  96. 3
      lib/site-lib.js
  97. 119
      lib/site-unzalgo.js
  98. 13
      package.json
  99. 192
      pnpm-lock.yaml
  100. 12
      restart

2
LICENSE

@ -1,2 +1,2 @@
DTP Time Tracker Copyright (C) 2024 DTP Technologies, LLC
DTP Base Copyright (C) 2024 DTP Technologies, LLC
All Rights Reserved

6
README.md

@ -1,6 +1,6 @@
# DTP Chat
# DTP Base
A no-nonsense/no-frills communications platform.
A production-ready Node.js application development framework for use in-house at DTP Technologies, LLC.
## System Requirements
@ -82,4 +82,4 @@ Edit `.bashrc` and set `NODE_ENV=production`
## Emoji Picker
Chat currently uses [emoji-picker-element](https://www.npmjs.com/package/emoji-picker-element) as the renderer of an emoji picker, and then it manages the presentation of the picker itself.
Base currently uses [emoji-picker-element](https://www.npmjs.com/package/emoji-picker-element) as the renderer of an emoji picker, and then it manages the presentation of the picker itself.

216
app/controllers/client.js

@ -1,216 +0,0 @@
// client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
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 {
static get name ( ) { return 'ClientController'; }
static get slug ( ) { return 'client'; }
constructor (dtp) {
super(dtp, ClientController.slug);
}
async start ( ) {
const { dtp } = this;
const {
limiter: limiterService,
session: sessionService,
} = dtp.services;
const limiterConfig = limiterService.config.client;
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true });
function checkClientOwnership (req, res, next) {
if (!res.locals.client.user._id.equals(req.user._id)) {
throw new SiteError(401, 'This is not your client');
}
return next();
}
function checkProjectOwnership (req, res, next) {
if (!res.locals.project.user._id.equals(req.user._id)) {
throw new SiteError(401, 'This is not your client');
}
return next();
}
const router = express.Router();
dtp.app.use('/client', authCheck, router);
router.param('clientId', populateClientId(this));
router.param('projectId', populateProjectId(this));
router.post(
'/:clientId/project/:projectId',
limiterService.create(limiterConfig.postProjectUpdate),
checkClientOwnership,
this.postProjectUpdate.bind(this),
);
router.post(
'/:clientId/project',
limiterService.create(limiterConfig.postProjectCreate),
checkClientOwnership,
this.postProjectCreate.bind(this),
);
router.post(
'/:clientId',
limiterService.create(limiterConfig.postClientUpdate),
checkClientOwnership,
this.postClientUpdate.bind(this),
);
router.post(
'/',
limiterService.create(limiterConfig.postClientCreate),
this.postClientCreate.bind(this),
);
router.get(
'/:clientId/project/create',
limiterService.create(limiterConfig.getProjectCreate),
checkClientOwnership,
this.getProjectCreate.bind(this),
);
router.get(
'/:clientId/project/:projectId/edit',
limiterService.create(limiterConfig.getProjectEditor),
checkProjectOwnership,
this.getProjectEditor.bind(this),
);
router.get(
'/:clientId/project/:projectId',
limiterService.create(limiterConfig.getProjectView),
checkProjectOwnership,
this.getProjectView.bind(this),
);
router.get(
'/create',
limiterService.create(limiterConfig.getClientEditor),
this.getClientEditor.bind(this),
);
router.get(
'/:clientId/edit',
limiterService.create(limiterConfig.getClientEditor),
checkClientOwnership,
this.getClientEditor.bind(this),
);
router.get(
'/:clientId',
limiterService.create(limiterConfig.getClientView),
checkClientOwnership,
this.getClientView.bind(this),
);
router.get(
'/',
limiterService.create(limiterConfig.getHome),
this.getHome.bind(this),
);
return router;
}
async postProjectUpdate (req, res, next) {
const { client: clientService } = this.dtp.services;
try {
await clientService.updateProject(res.locals.project, req.body);
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`);
} catch (error) {
this.log.error('failed to update client project', { error });
return next(error);
}
}
async postProjectCreate (req, res, next) {
const { client: clientService } = this.dtp.services;
try {
res.locals.project = await clientService.createProject(res.locals.client, req.body);
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`);
} catch (error) {
this.log.error('failed to create client project', { error });
return next(error);
}
}
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);
res.redirect(`/client/${res.locals.client._id}`);
} catch (error) {
this.log.error('failed to create client', { error });
return next(error);
}
}
async getProjectCreate (req, res) {
res.render('client/project/editor');
}
async getProjectEditor (req, res) {
res.render('client/project/editor');
}
async getProjectView (req, res, next) {
const { task: taskService } = this.dtp.services;
try {
res.locals.taskGrid = await taskService.getTaskGridForProject(res.locals.project);
res.render('client/project/view');
} catch (error) {
this.log.error('failed to present project view', { error });
return next(error);
}
}
async getClientEditor (req, res) {
res.render('client/editor');
}
async getClientView (req, res, next) {
const { client: clientService } = this.dtp.services;
try {
res.locals.projects = await clientService.getProjectsForClient(res.locals.client);
res.render('client/view');
} catch (error) {
this.log.error('failed to present client home view', { error });
return next(error);
}
}
async getHome (req, res, next) {
const { client: clientService } = this.dtp.services;
try {
res.locals.clients = await clientService.getClientsForUser(req.user);
res.render('client/dashboard');
} catch (error) {
this.log.error('failed to present client home view', { error });
return next(error);
}
}
}

12
app/controllers/home.js

@ -28,23 +28,13 @@ export default class HomeController extends SiteController {
}
async getHome (req, res, next) {
const { client: clientService, report: reportService, task: taskService } = this.dtp.services;
try {
if (!req.user) {
return res.redirect('/welcome');
}
res.locals.currentView = 'home';
res.locals.pageDescription = 'DTP Time Tracker';
res.locals.projects = await clientService.getProjectsForUser(req.user);
res.locals.taskGrid = await taskService.getTaskGridForUser(req.user);
res.locals.weeklyEarnings = await reportService.getWeeklyEarnings(req.user);
res.locals.managedProjects = await clientService.getProjectsForManager(req.user);
for (const project of res.locals.managedProjects) {
project.taskGrid = await taskService.getTaskGridForProject(project);
}
res.locals.pageDescription = 'DTP Base';
res.render('home');
} catch (error) {

73
app/controllers/lib/populators.js

@ -2,71 +2,22 @@
import { SiteError } from '../../../lib/site-lib.js';
export function populateClientId (controller) {
return async function (req, res, next, clientId) {
const { client: clientService } = controller.dtp.services;
/*
* This is a sample populator. It doesn't run. There is no sample service, etc.
* This is simply the pattern you follow to declare a new ExpressJS parameter
* populator and export it from your populators library.
*/
export function populateSampleParameter (controller) {
return async function (req, res, next, sampleParameter) {
const { sample: sampleService } = 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');
res.locals.sample = await sampleService.getSample(sampleParameter);
if (!res.locals.sample) {
throw new SiteError(404, 'Sample not found');
}
return next();
} catch (error) {
controller.log.error('failed to populate sessionId', { sessionId, error });
controller.log.error('failed to populate sampleParameter', { sampleParamater, error });
return next(error);
}
};

43
app/controllers/manager.js

@ -1,43 +0,0 @@
// 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');
}
}

52
app/controllers/report.js

@ -1,52 +0,0 @@
// report.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import express from 'express';
import { SiteController } from '../../lib/site-lib.js';
export default class ReportController extends SiteController {
static get name ( ) { return 'ReportController'; }
static get slug ( ) { return 'report'; }
constructor (dtp) {
super(dtp, ReportController.slug);
}
async start ( ) {
const {
// limiter: limiterService,
session: sessionService,
} = this.dtp.services;
// const limiterConfig = limiterService.config.report;
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true });
const router = express.Router();
this.dtp.app.use('/report', authCheck, router);
router.get('/', this.getDashboard.bind(this));
}
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 });
return next(error);
}
}
}

257
app/controllers/task.js

@ -1,257 +0,0 @@
// task.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
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 {
static get name ( ) { return 'TaskController'; }
static get slug ( ) { return 'task'; }
constructor (dtp) {
super(dtp, TaskController);
}
async start ( ) {
const { dtp } = this;
const {
limiter: limiterService,
session: sessionService,
} = dtp.services;
const limiterConfig = limiterService.config.task;
const multer = this.createMulter(TaskController.slug, {
limits: {
fileSize: 1024 * 1000 * 5,
},
});
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true });
const router = express.Router();
dtp.app.use('/task', authCheck, router);
router.use(async (req, res, next) => {
res.locals.currentView = TaskController.name;
return next();
});
async function checkTaskOwnership (req, res, next) {
if (Array.isArray(res.locals.task.project.managers) && (res.locals.task.project.managers.length > 0)) {
res.locals.manager = res.locals.task.project.managers.find((manager) => manager._id.equals(req.user._id));
}
if (!res.locals.manager && !res.locals.task.user._id.equals(req.user._id)) {
return next(new SiteError(401, 'This is not your task'));
}
return next();
}
async function checkSessionOwnership (req, res, next) {
if (Array.isArray(res.locals.task.project.managers) && (res.locals.task.project.managers.length > 0)) {
res.locals.manager = res.locals.task.project.managers.find((manager) => manager._id.equals(req.user._id));
}
if (!res.locals.manager && !res.locals.session.user._id.equals(req.user._id)) {
throw new SiteError(401, 'This is not your session');
}
return next();
}
router.param('taskId', populateTaskId(this));
router.param('sessionId', populateSessionId(this));
router.post(
'/:taskId/session/start',
limiterService.create(limiterConfig.postStartTaskSession),
this.postStartTaskSession.bind(this),
);
router.post(
'/:taskId/session/:sessionId/screenshot',
checkSessionOwnership,
limiterService.create(limiterConfig.postTaskSessionScreenshot),
multer.single('image'),
this.postTaskSessionScreenshot.bind(this),
);
router.post(
'/:taskId/session/:sessionId/status',
limiterService.create(limiterConfig.postTaskSessionStatus),
checkSessionOwnership,
this.postTaskSessionStatus.bind(this),
);
router.post(
'/:taskId/session/:sessionId/close',
limiterService.create(limiterConfig.postCloseTaskSession),
checkSessionOwnership,
this.postCloseTaskSession.bind(this),
);
router.post(
'/:taskId/start',
limiterService.create(limiterConfig.postStartTask),
checkTaskOwnership,
this.postStartTask.bind(this),
);
router.post(
'/:taskId/close',
limiterService.create(limiterConfig.postCloseTask),
checkTaskOwnership,
this.postCloseTask.bind(this),
);
router.post(
'/',
limiterService.create(limiterConfig.postCreateTask),
this.postCreateTask.bind(this),
);
router.get(
'/:taskId/session/:sessionId',
limiterService.create(limiterConfig.getTaskSessionView),
checkSessionOwnership,
this.getTaskSessionView.bind(this),
);
router.get(
'/:taskId',
limiterService.create(limiterConfig.getTaskView),
checkTaskOwnership,
this.getTaskView.bind(this),
);
}
async postStartTaskSession (req, res) {
const { task: taskService } = this.dtp.services;
try {
res.locals.session = await taskService.createTaskSession(res.locals.task);
res.status(200).json({
success: true,
session: res.locals.session,
});
} catch (error) {
this.log.error('failed to create task session', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postTaskSessionScreenshot (req, res) {
const { task: taskService } = this.dtp.services;
try {
await taskService.addTaskSessionScreenshot(res.locals.session, req.file);
res.status(200).json({ success: true });
} catch (error) {
this.log.error('failed to add task session screenshot', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postTaskSessionStatus (req, res) {
const { task: taskService } = this.dtp.services;
try {
this.log.debug('updating task session status', {
sessionId: res.locals.session._id,
params: req.body,
});
await taskService.setTaskSessionStatus(res.locals.session, req.body.status);
const displayList = this.createDisplayList('set-session-status');
displayList.showNotification(
'Session status updated',
'success',
'bottom-center',
5000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update task session status', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postCloseTaskSession (req, res) {
const { task: taskService } = this.dtp.services;
try {
await taskService.closeTaskSession(res.locals.session);
res.status(200).json({ success: true });
} catch (error) {
this.log.error('failed to close task session', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postStartTask (req, res, next) {
const { task: taskService } = this.dtp.services;
try {
await taskService.startTask(res.locals.task);
res.redirect(`/task/${res.locals.task._id}`);
} catch (error) {
this.log.error('failed to close task session', { error });
return next(error);
}
}
async postCloseTask (req, res, next) {
const { task: taskService } = this.dtp.services;
try {
await taskService.closeTask(res.locals.task);
res.redirect('/');
} catch (error) {
this.log.error('failed to close task', { error });
return next(error);
}
}
async postCreateTask (req, res, next) {
const { task: taskService } = this.dtp.services;
try {
res.locals.task = await taskService.createTask(req.user, req.body);
res.redirect(`/task/${res.locals.task._id}`);
} catch (error) {
this.log.error('failed to create new task', { error });
return next(error);
}
}
async getTaskSessionView (req, res) {
res.render('task/session/view');
}
async getTaskView (req, res, next) {
const { report: reportService, task: taskService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 50);
res.locals.sessions = await taskService.getSessionsForTask(
res.locals.task,
res.locals.pagination,
);
res.locals.weekStartDate = reportService.startOfWeek();
res.render('task/view');
} catch (error) {
this.log.error('failed to present the Task view', { error });
return next(error);
}
}
}

20
app/models/client-project.js

@ -1,20 +0,0 @@
// project.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
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 },
});
export default mongoose.model('ClientProject', ClientProjectSchema);

23
app/models/client.js

@ -1,23 +0,0 @@
// client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
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);

5
app/models/lib/constants.js

@ -1,4 +1,5 @@
'use strict';
export const MIN_ROOM_CAPACITY = 4;
export const MAX_ROOM_CAPACITY = 25;
/*
* Declare model constants here and import them into whatever needs them.
*/

23
app/models/lib/media.js

@ -7,15 +7,6 @@
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const EPISODE_STATUS_LIST = [
'starting', // the stream is connecting
'live', // the stream is live
'ending', // the stream has ended, queued for processing
'processing', // the stream is being processed for DVR
'replay', // the stream is available on the DVR
'expired', // the stream is expired (removed)
];
const VIDEO_STATUS_LIST = [
'new', // the video (original) is on storage / queued
'processing', // the video is being processed for distribution
@ -23,15 +14,7 @@ const VIDEO_STATUS_LIST = [
'removed', // the video has been removed
];
const ROOM_STATUS_LIST = [
'starting', // the room is starting a call
'live', // the room has a live call
'shutdown', // the room is closing it's live call
'expired', // the room has failed to check in
'crashed', // the room's worker or server has crashed
];
const MediaMetadataSchema = new Schema({
const VideoMetadataSchema = new Schema({
type: { type: String },
size: { type: Number },
bitRate: { type: Number },
@ -58,9 +41,7 @@ const AudioMetadataSchema = new Schema({
});
export {
EPISODE_STATUS_LIST,
VIDEO_STATUS_LIST,
ROOM_STATUS_LIST,
MediaMetadataSchema,
VideoMetadataSchema,
AudioMetadataSchema,
};

37
app/models/task-session.js

@ -1,37 +0,0 @@
// task-session.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const STATUS_LIST = [
'active',
'reconnecting',
'finished',
'expired',
];
const ScreenshotSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: 1 },
image: { type: Schema.ObjectId, required: true, ref: 'Image' },
});
const TaskSessionSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: 1 },
lastUpdated: { type: Date, required: true, default: Date.now, index: 1 },
finished: { type: Date, index: 1 },
duration: { type: Number, default: 0, required: true },
hourlyRate: { type: Number, required: true },
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' },
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' },
task: { type: Schema.ObjectId, required: true, index: 1, ref: 'Task' },
status: { type: String, enum: STATUS_LIST, default: 'active', required: true },
notes: { type: [String], default: [ ] },
screenshots: { type: [ScreenshotSchema] },
});
export default mongoose.model('TaskSession', TaskSessionSchema);

23
app/models/task.js

@ -1,23 +0,0 @@
// task.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const STATUS_LIST = ['pending', 'active', 'finished'];
const TaskSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: 1 },
finished: { type: Date, index: 1 },
duration: { type: Number, default: 0, required: true },
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' },
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' },
status: { type: String, enum: STATUS_LIST, default: 'pending', required: true },
note: { type: String, required: true },
});
export default mongoose.model('Task', TaskSchema);

2
app/models/user.js

@ -45,7 +45,7 @@ const UserSchema = new Schema({
},
bio: { type: String },
ui: {
theme: { type: String, default: 'tracker-light', required: true },
theme: { type: String, default: 'dtp-light', required: true },
},
flags: { type: UserFlagsSchema, default: { }, required: true, select: false },
permissions: { type: UserPermissionsSchema, default: { }, required: true, select: false },

4
app/models/video.js

@ -9,7 +9,7 @@ const Schema = mongoose.Schema;
import {
VIDEO_STATUS_LIST,
MediaMetadataSchema,
VideoMetadataSchema,
} from './lib/media.js';
const VideoSchema = new Schema({
@ -24,7 +24,7 @@ const VideoSchema = new Schema({
media: {
bucket: { type: String, required: true },
key: { type: String, required: true },
metadata: { type: MediaMetadataSchema },
metadata: { type: VideoMetadataSchema },
},
});

293
app/services/client.js

@ -1,293 +0,0 @@
// client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Client = mongoose.model('Client');
const ClientProject = mongoose.model('ClientProject');
import { SiteService, SiteError } from '../../lib/site-lib.js';
export default class ClientService extends SiteService {
static get name ( ) { return 'ClientService'; }
static get slug () { return 'client'; }
constructor (dtp) {
super(dtp, ClientService);
}
async start ( ) {
const { user: userService } = this.dtp.services;
this.populateClient = [
{
path: 'user',
select: userService.USER_SELECT,
},
];
this.populateClientProject = [
{
path: 'user',
select: userService.USER_SELECT,
},
{
path: 'client',
populate: this.populateClient,
},
{
path: 'managers',
select: userService.USER_SELECT,
},
];
}
async createClient (user, clientDefinition) {
const { text: textService } = this.dtp.services;
const client = new Client();
client.user = user._id;
client.name = textService.filter(clientDefinition.name);
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 })
.populate(this.populateClient)
.lean();
return client;
}
async getClientsForUser (user) {
const clients = Client
.find({ user: user._id })
.sort({ name: 1 })
.populate(this.populateClient)
.lean();
return clients;
}
async createProject (client, projectDefinition) {
const { text: textService } = this.dtp.services;
const project = new ClientProject();
project.user = client.user._id;
project.client = client._id;
project.name = textService.filter(projectDefinition.name);
if (projectDefinition.description && (projectDefinition.description.length > 0)) {
project.description = textService.filter(projectDefinition.description);
}
if (projectDefinition.managers && (projectDefinition.managers.length > 0)) {
project.managers = await this.parseManagerNames(projectDefinition.managers);
}
project.hourlyRate = projectDefinition.hourlyRate;
await project.save();
return project.toObject();
}
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.managers && (projectDefinition.managers.length > 0)) {
update.$set.managers = await this.parseManagerNames(projectDefinition.managers);
changed = true;
} else {
update.$unset.managers = 1;
}
if (projectDefinition.hourlyRate !== project.hourlyRate) {
update.$set.hourlyRate = projectDefinition.hourlyRate;
changed = true;
}
if (!changed) {
return;
}
await ClientProject.updateOne({ _id: project._id }, update);
}
async parseManagerNames (managerNames) {
const { user: userService } = this.dtp.services;
const managers = [ ];
managerNames = managerNames.split(',').map((m) => m.trim().toLowerCase());
for (const name of managerNames) {
const user = await userService.getByUsername(name);
if (!user) {
throw new SiteError(400, `Trying to set unknown user "${name}" as project manager`);
}
managers.push(user._id);
}
return managers;
}
async getProjectById (projectId) {
const project = ClientProject
.findOne({ _id: projectId })
.populate(this.populateClientProject)
.lean();
return project;
}
async getProjectsForUser (user) {
const projects = await ClientProject
.find({ user: user._id })
.sort({ name: 1 })
.populate(this.populateClientProject)
.lean();
return projects;
}
async getProjectsForClient (client) {
const projects = await ClientProject
.find({ client: client._id })
.sort({ name: 1 })
.populate(this.populateClientProject)
.lean();
return projects;
}
async getProjectsForManager (manager) {
const projects = await ClientProject
.find({ managers: manager._id })
.sort({ name: 1 })
.populate(this.populateClientProject)
.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', {
cmd: 'end-session',
displayList,
});
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 },
},
);
}
}

141
app/services/report.js

@ -1,141 +0,0 @@
// report.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const TaskSession = mongoose.model('TaskSession');
import dayjs from 'dayjs';
import { SiteService } from '../../lib/site-lib.js';
export default class ReportService extends SiteService {
static get name ( ) { return 'ReportService'; }
static get slug () { return 'report'; }
constructor (dtp) {
super(dtp, ReportService);
}
async getWeeklyEarnings (user) {
const { client: clientService } = this.dtp.services;
const NOW = new Date();
const dateStart = this.startOfWeek(NOW);
const dateEnd = dayjs(dateStart).add(1, 'week').toDate();
const data = await TaskSession.aggregate([
{
$match: {
user: user._id,
$and: [
{ created: { $gte: dateStart } },
{ finished: { $lt: dateEnd } },
],
},
},
{
$group: {
_id: { project: '$project' },
sessionCount: { $sum: 1 },
duration: { $sum: '$duration' },
billable: {
$sum: {
$multiply: [
'$hourlyRate',
{ $divide: ['$duration', 3600 ] },
],
},
},
},
},
{
$project: {
_id: 0,
project: '$_id.project',
sessionCount: '$sessionCount',
duration: '$duration',
billable: '$billable',
},
},
]);
const response = await TaskSession.populate(data, [
{
path: 'project',
populate: clientService.populateClientProject,
},
]);
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);
var diff = date.getDate() - date.getDay() + (date.getDay() === 0 ? -6 : 1);
return new Date(date.setDate(diff));
}
}

329
app/services/task.js

@ -1,329 +0,0 @@
// task.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Task = mongoose.model('Task');
const TaskSession = mongoose.model('TaskSession');
import dayjs from 'dayjs';
import { SiteService, SiteError } from '../../lib/site-lib.js';
export default class TaskService extends SiteService {
static get name ( ) { return 'TaskService'; }
static get slug () { return 'task'; }
constructor (dtp) {
super(dtp, TaskService);
}
async start ( ) {
const { client: clientService, user: userService } = this.dtp.services;
// const { jobQueue: jobQueueService } = this.dtp.services;
// this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links);
this.populateTask = [
{
path: 'user',
select: userService.USER_SELECT,
},
{
path: 'client',
populate: clientService.populateClient,
},
{
path: 'project',
populate: clientService.populateClientProject,
},
];
this.populateTaskSession = [
{
path: 'user',
select: userService.USER_SELECT,
},
{
path: 'client',
populate: clientService.populateClient,
},
{
path: 'project',
populate: clientService.populateClientProject,
},
{
path: 'task',
populate: this.populateTask,
},
{
path: 'screenshots.image',
}
];
}
async createTask (user, taskDefinition) {
const { text: textService } = this.dtp.services;
const NOW = new Date();
const task = new Task();
task.created = NOW;
task.user = user._id;
task.client = mongoose.Types.ObjectId.createFromHexString(taskDefinition.clientId);
task.project = mongoose.Types.ObjectId.createFromHexString(taskDefinition.projectId);
task.status = 'pending';
task.note = textService.filter(taskDefinition.note);
await task.save();
return task.toObject();
}
async startTask (task) {
if (task.status !== 'pending') {
throw new SiteError(400, 'The task is not in the pending state');
}
await Task.updateOne(
{ _id: task._id },
{ $set: { status: 'active' } },
);
}
async closeTask (task) {
await TaskSession
.find({ task: task._id, status: 'active' })
.cursor()
.eachAsync(async (session) => {
await this.closeTaskSession(session);
});
await Task.updateOne(
{ _id: task._id },
{ $set: { status: 'finished' } },
);
}
async getTasksForUser (user, options, pagination) {
const search = { user: user._id };
options = options || { };
if (options.pending) {
search.status = 'pending';
} else if (options.active) {
search.status = 'active';
} else if (options.finished) {
search.status = 'finished';
}
const tasks = await Task
.find(search)
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateTask)
.lean();
return tasks.reverse();
}
async getTasksForProject (project, options, pagination) {
const search = { project: project._id };
options = options || { };
if (options.pending) {
search.status = 'pending';
} else if (options.active) {
search.status = 'active';
} else if (options.finished) {
search.status = 'finished';
}
const tasks = await Task
.find(search)
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateTask)
.lean();
return tasks.reverse();
}
async getTaskGridForUser (user) {
const pagination = { skip: 0, cpp: 10 };
const pendingTasks = await this.getTasksForUser(user, { pending: true }, pagination);
const activeTasks = await this.getTasksForUser(user, { active: true }, pagination);
const finishedTasks = await this.getTasksForUser(user, { finished: true }, pagination);
return { pendingTasks, activeTasks, finishedTasks };
}
async getTaskGridForProject (project) {
const pagination = { skip: 0, cpp: 10 };
const pendingTasks = await this.getTasksForProject(project, { pending: true }, pagination);
const activeTasks = await this.getTasksForProject(project, { active: true }, pagination);
const finishedTasks = await this.getTasksForProject(project, { finished: true }, pagination);
return { pendingTasks, activeTasks, finishedTasks };
}
async getTaskById (taskId) {
const task = await Task
.findOne({ _id: taskId })
.populate(this.populateTask)
.lean();
return task;
}
async createTaskSession (task) {
const NOW = new Date();
if (await TaskSession.findOne({ user: task.user._id, status: 'active' })) {
throw new SiteError(401, "Can't start new session with a currently active session.");
}
let session = new TaskSession();
session.created = NOW;
session.lastUpdated = NOW;
session.hourlyRate = task.project.hourlyRate;
session.user = task.user._id;
session.client = task.client._id;
session.project = task.project._id;
session.task = task._id;
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();
}
async addTaskSessionScreenshot (session, file) {
const NOW = new Date();
const { image: imageService } = this.dtp.services;
const image = await imageService.create(session.user._id, { }, file);
await TaskSession.updateOne(
{ _id: session._id },
{
$push: {
screenshots: {
created: NOW,
image: image._id,
},
},
},
);
const displayList = this.createDisplayList('screenshot-accepted');
displayList.showNotification(
'Screenshot accepted',
'success',
'bottom-center',
3000,
);
this.dtp.emitter
.to(session.task._id.toString())
.emit('session-control', {
displayList,
});
}
async setTaskSessionStatus (session, status) {
if (status === session.status) {
return; // do nothing
}
if (!['active', 'reconnecting'].includes(status)) {
throw new SiteError(400, 'Can only set status to active or reconnecting');
}
this.log.info('updating task session status', {
user: {
_id: session.user._id,
username: session.user.username,
},
session: { _id: session._id },
status,
});
await TaskSession.updateOne({ _id: session._id }, { $set: { status } });
}
async closeTaskSession (session, status = 'finished') {
const { client: clientService } = this.dtp.services;
const NOW = new Date();
if (session.status !== 'active') {
throw new SiteError(400, 'The session is not currently active');
}
const duration = dayjs(NOW).diff(session.created, 'second');
await TaskSession.updateOne(
{ _id: session._id },
{
$set: {
finished: NOW,
status,
duration,
},
},
);
await Task.updateOne(
{ _id: session.task._id },
{
$inc: { duration },
},
);
await clientService.addTimeWorked(session.client, duration);
this.log.info('task session closed', {
user: {
_id: session.user._id,
username: session.user.username,
},
duration,
});
}
async getSessionsForTask (task, pagination) {
const sessions = TaskSession
.find({ task: task._id })
.sort({ created: 1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateTaskSession)
.lean();
return sessions;
}
async getTaskSessionById (sessionId) {
const session = await TaskSession
.findOne({ _id: sessionId })
.populate(this.populateTaskSession)
.lean();
return session;
}
async closeTaskSessionForUser (user) {
await TaskSession
.find({ user: user._id, status: 'active' })
.populate(this.populateTaskSession)
.cursor()
.eachAsync(async (session) => {
await this.closeTaskSession(session);
});
}
}

9
app/services/text.js

@ -10,12 +10,15 @@ const User = mongoose.model('User');
const Link = mongoose.model('Link');
import striptags from 'striptags';
import unzalgo from 'unzalgo';
import shoetest from 'shoetest';
import diacritics from 'diacritics';
import DtpTextFilter from './lib/edit-with-vi.js';
import { SiteService, SiteError } from '../../lib/site-lib.js';
import {
SiteService,
SiteError,
SiteUnzalgo,
} from '../../lib/site-lib.js';
export default class TextService extends SiteService {
@ -37,7 +40,7 @@ export default class TextService extends SiteService {
* @returns The cleaned text
*/
clean (text) {
text = unzalgo.clean(text);
text = SiteUnzalgo.clean(text);
text = striptags(text.trim());
return text;
}

24
app/views/client/dashboard.pug

@ -1,24 +0,0 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-margin
div(uk-grid)
.uk-width-expand
h1 Clients
.uk-width-auto
a(href="/client/create").uk-button.uk-button-default.uk-border-rounded Create Client
.uk-margin
if Array.isArray(clients) && (clients.length > 0)
ul.uk-list.uk-list-divider
each client in clients
li
a(href=`/client/${client._id}`).uk-link-reset.uk-display-block
.uk-text-lead= client.name
div= client.description || 'No description'
else
div There are no clients

54
app/views/client/editor.pug

@ -1,54 +0,0 @@
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
div(uk-grid).uk-grid-small.uk-flex-right
.uk-width-auto
a(href=`/client/${client._id}`).uk-button.uk-button-default.uk-border-rounded
i.fa-solid.fa-chevron-left
span.uk-margin-small-left Back
.uk-width-auto
button(type="submit").uk-button.uk-button-default.uk-border-rounded
i.fa-solid.fa-save
span.uk-margin-small-left #{!!client ? "Update" : "Create"} Client

60
app/views/client/project/editor.pug

@ -1,60 +0,0 @@
extends ../../layout/main
block view-content
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-margin
- var actionUrl = !!project ? `/client/${client._id}/project/${project._id}` : `/client/${client._id}/project`;
form(method="POST", action= actionUrl).uk-form
.uk-card.uk-card-default.uk-card-small
.uk-card-header
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
h1.uk-card-title #[span= !!project ? "Edit" : "New"] Project
.uk-width-auto
div #{client.name}
.uk-card-body
.uk-margin
label(for="name").uk-form-label Project name
input(
id="name",
name="name",
type="text",
value= !!project ? project.name : undefined,
placeholder="Enter project name",
).uk-input
.uk-margin
label(for="description").uk-form-label Project description
textarea(
id="name",
name="description",
rows=4,
placeholder="Enter project description"
).uk-textarea.uk-resize-vertical= !!project ? project.description : ""
.uk-margin
label(for="managers").uk-form-label Managers
input(
id="managers",
name="managers",
type="text",
value= !!project ? (project.managers || [ ]).map((m) => m.username).join(',') : undefined,
placeholder="Enter comma-separated list of usernames",
).uk-input
.uk-text-small.uk-text-muted Managers can review work sessions and access billing information.
.uk-margin
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

39
app/views/client/project/view.pug

@ -1,39 +0,0 @@
extends ../../layout/main
block view-content
include ../../task/components/grid
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-margin
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
h1.uk-margin-remove= project.name
.uk-width-auto
a(href=`/client/${client._id}/project/${project._id}/edit`).uk-button.uk-button-default.uk-button-small
i.fa-solid.fa-cog
span.uk-margin-small-left Settings
.uk-width-auto
.uk-text-bold
a(href=`/client/${client._id}`).uk-link-reset= client.name
.uk-text-small #{numeral(project.hourlyRate).format('$0,0.00')}/hr
if project.description
div= project.description
.uk-margin-medium
form(method="POST", action="/task").uk-form
input(type="hidden", name="clientId", value= client._id)
input(type="hidden", name="projectId", value= project._id)
.uk-card.uk-card-secondary.uk-card-small.uk-border-rounded
.uk-card-body.uk-border-rounded
label(for="note").uk-form-label.sr-only Task note
div(uk-grid).uk-grid-small
.uk-width-expand
input(id="note", name="note", placeholder="What will you be working on?").uk-input.uk-border-rounded
.uk-width-auto
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Task
.uk-margin-medium
+renderTaskGrid(taskGrid.pendingTasks, taskGrid.activeTasks, taskGrid.finishedTasks)

32
app/views/client/view.pug

@ -1,32 +0,0 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-margin-medium
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 Settings
.uk-width-auto
a(href=`/client/${client._id}/project/create`).uk-button.uk-button-default.uk-button-small
i.fa-solid.fa-plus
span.uk-margin-small-left Add Project
.uk-margin
h2 Projects
if Array.isArray(projects) && (projects.length > 0)
ul.uk-list.uk-list-divided
each project in projects
li
a(href=`/client/${client._id}/project/${project._id}`).uk-link-reset.uk-display-block
.uk-text-bold= project.name
.uk-text-small= project.description || 'No description'
else
div There are no projects

7
app/views/components/navbar.pug

@ -9,13 +9,6 @@ nav(style="background: #000000;").uk-navbar-container.uk-light
li
a(href="/", aria-label="Back to Home").uk-navbar-item
.uk-text-bold.no-select Home
if user
li
a(href="/client", aria-label="Manage your clients").uk-navbar-item
.uk-text-bold.no-select Clients
li
a(href="/report", aria-label="View your work reports").uk-navbar-item
.uk-text-bold.no-select Reports
.uk-navbar-right
if !user

39
app/views/home.pug

@ -1,39 +1,8 @@
extends layout/main
block view-content
include task/components/grid
include report/components/weekly-summary
section.uk-section.uk-section-default.uk-section-small
.uk-container
if Array.isArray(projects) && (projects.length > 0)
section.uk-section.uk-section-muted.uk-section-small
.uk-container
h1 Your Projects
if weeklyEarnings.length > 0
+renderWeeklySummaryReport(weeklyEarnings)
else
div No time worked this week.
section.uk-section.uk-section-default.uk-section-small
.uk-container
+renderTaskGrid(
taskGrid.pendingTasks,
taskGrid.activeTasks,
taskGrid.finishedTasks,
)
if Array.isArray(managedProjects) && (managedProjects.length > 0)
section.uk-section.uk-section-muted.uk-section-small
.uk-container
h1 Projects You Manage
section.uk-section.uk-section-default.uk-section-small
.uk-container
ul.uk-list.uk-list-divider
each project in managedProjects
li
h2= project.name
+renderTaskGrid(
project.taskGrid.pendingTasks,
project.taskGrid.activeTasks,
project.taskGrid.finishedTasks,
)
h1= site.name
p Welcome to the authenticated home page. You are logged in. This app does nothing, so get to work building your features and ship often!

2
app/views/layout/main.pug

@ -22,7 +22,7 @@ html(lang='en', data-obs-widget= obsWidget)
block vendorcss
link(rel='stylesheet', href=`/dist/${(user) ? user.ui.theme : 'tracker-light'}.css?v=${pkg.version}`)
link(rel='stylesheet', href=`/dist/${(user) ? user.ui.theme : 'dtp-light'}.css?v=${pkg.version}`)
link(rel='stylesheet', href=`/pretty-checkbox/pretty-checkbox.min.css?v=${pkg.version}`)
block viewcss

12
app/views/manager/dashboard.pug

@ -1,12 +0,0 @@
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.

20
app/views/report/components/weekly-summary.pug

@ -1,20 +0,0 @@
mixin renderWeeklySummaryReport (data)
.uk-overflow-auto
table.uk-table.uk-table-small.no-select
thead
tr
th Project
th Client
th Sessions
th Time Worked
th Billable
tbody
each row in data
tr
td.uk-table-expand
a(href=`/client/${row.project.client._id}/project/${row.project._id}`)=row.project.name
td
a(href=`/client/${row.project.client._id}`)= row.project.client.name
td= formatCount(row.sessionCount)
td= numeral(row.duration).format('0:00:00')
td= numeral(row.billable).format('$0,0.00')

41
app/views/report/dashboard.pug

@ -1,41 +0,0 @@
extends ../layout/main
block view-content
include components/weekly-summary
section.uk-section.uk-section-default.uk-section
.uk-container
h1 Week of #{dayjs(weekStartDate).format('MMMM DD')}
+renderWeeklySummaryReport(weeklyEarnings)
.uk-margin-medium
h2 Daily Hours
.uk-overflow-auto
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 * 3600).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')

23
app/views/task/components/grid.pug

@ -1,23 +0,0 @@
include list
mixin renderTaskGrid (pendingTasks, activeTasks, finishedTasks)
div(uk-grid).uk-grid-divider
div(class="uk-width1-1 uk-width-1-3@m")
h3 Pending Tasks
if Array.isArray(pendingTasks) && (pendingTasks.length > 0)
+renderTaskList(pendingTasks)
else
div No pending tasks
div(class="uk-width1-1 uk-width-1-3@m")
h3 Active Tasks
if Array.isArray(activeTasks) && (activeTasks.length > 0)
+renderTaskList(activeTasks)
else
div No active tasks
div(class="uk-width1-1 uk-width-1-3@m")
h3 Finished Tasks
if Array.isArray(finishedTasks) && (finishedTasks.length > 0)
+renderTaskList(finishedTasks)
else
div No finished tasks

11
app/views/task/components/list.pug

@ -1,11 +0,0 @@
mixin renderTaskList (tasks)
ul.uk-list.uk-list-divider
each task in tasks
li
a(href=`/task/${task._id}`).uk-display-block.uk-link-reset
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand= task.note
.uk-width-auto
.uk-text-small= numeral(task.duration).format('00:00:00')
.uk-text-small.uk-text-muted
span #{task.project.name}, #{task.client.name}

55
app/views/task/session/view.pug

@ -1,55 +0,0 @@
extends ../../layout/main
block view-content
include ../../user/components/profile-picture
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-margin-medium
div(uk-grid).uk-grid-small
.uk-width-auto
+renderProfilePicture(user)
.uk-width-expand
.uk-text-large= user.displayName || user.username
div Work session #[code #{session._id}] for task "#{task.note}"
.uk-width-auto.uk-text-right
div= numeral(session.duration).format('HH:MM:SS')
.uk-text-small #{numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,0.00')}
.uk-margin-medium
.uk-flex.uk-flex-between.uk-flex-wrap
.uk-width-auto
.uk-margin
.uk-text-small.uk-text-bold Created
div= dayjs(session.created).format('MMM DD, YYYY [at] hh:mm:ss a')
.uk-width-auto
.uk-margin
.uk-text-small.uk-text-bold Last Updated
div= dayjs(session.lastUpdated).format('MMM DD, YYYY [at] hh:mm:ss a')
.uk-width-auto
.uk-margin
.uk-text-small.uk-text-bold Finished
div= dayjs(session.finished).format('MMM DD, YYYY [at] hh:mm:ss a')
.uk-width-auto
.uk-margin
.uk-text-small.uk-text-bold Project
div= session.project.name
.uk-width-auto
.uk-margin
.uk-text-small.uk-text-bold Client
div= session.client.name
.uk-margin-medium
if Array.isArray(session.screenshots) && (session.screenshots.length > 0)
h3 Screenshots
div(class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small
each screenshot in session.screenshots
a(href=`/image/${screenshot.image._id}`,
data-type="image",
data-caption= `${dayjs(screenshot.created).format('MMM DD [at] h:mm a')} | ${screenshot.image.metadata.format.toUpperCase()} | ${numeral(screenshot.image.size).format('0,0.0b')}`,
).uk-link-reset
img(src=`/image/${screenshot.image._id}`, width= screenshot.image.metadata.width, height= screenshot.image.metadata.height, alt="Image attachment")
.uk-text-small.uk-text-muted= dayjs(screenshot.created).format('MMM DD [at] h:mm a')
else
div No screenshots were filed by the session.

102
app/views/task/view.pug

@ -1,102 +0,0 @@
extends ../layout/main
block view-content
include ../user/components/profile-picture
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-margin
div(uk-grid)
div(class="uk-width-1-1 uk-width-expand@m")
.uk-margin-medium
div(uk-grid).uk-grid-small
.uk-width-auto
+renderProfilePicture(user)
.uk-width-expand
div(style="line-height: 1;").uk-text-lead.uk-text-truncated.uk-margin-small= task.note
if !manager
case task.status
when 'pending'
.uk-margin-small
form(method="POST", action=`/task/${task._id}/start`).uk-form
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Start Task
when 'active'
.uk-margin-small
form(method="POST", action=`/task/${task._id}/close`).uk-form
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Finish Task
when 'finished'
.uk-text-success Finished task
else
div Task status: #{task.status}
if task.status === 'active' && !manager
.uk-width-auto.uk-text-right
.pretty.p-switch.p-slim
input(
id="active-toggle",
type="checkbox",
data-task-id= task._id,
onchange="return dtp.app.taskActivityToggle(event);",
)
.state.p-success
label(for="active-toggle")
span.sr-only Active
#current-session-duration.uk-text-small.no-select= numeral(0).format('HH:MM:SS')
- 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
.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)
-
var totalTimeWorked = 0;
var totalBillable = 0;
table.uk-table.uk-table-small.uk-table-divider
thead
tr
th.uk-table-expand Start Time
th.uk-text-nowrap.uk-table-shrink Tracked
th.uk-text-nowrap.uk-table-shrink Billable
tbody
each session in sessions
-
totalTimeWorked += session.duration;
totalBillable += session.hourlyRate * (session.duration / 3600);
tr
td.uk-table-expand
a(href=`/task/${task._id}/session/${session._id}`,
onclick="return dtp.app.performSessionNavigation(event);",
).uk-link-reset.uk-display-block= dayjs(session.created).format('dddd [at] h:mm a')
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.duration).format('HH:MM:SS')
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.hourlyRate * (session.duration / 3600)).format('$0,00.00')
tfoot
tr
td.uk-table-expand TOTALS
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalTimeWorked).format('HH:MM:SS')
td.uk-text-right.uk-text-nowrap.uk-table-shrink #{numeral(totalBillable).format('$0,0.00')}
else
div No work sessions
if !manager && (task.status === 'active')
div(class="uk-width-1-1 uk-flex-first uk-width-large@m uk-flex-last@m")
.uk-margin
video(
id="capture-preview",
poster="/img/default-poster.svg",
playsinline, muted,
).dtp-video.no-select
div(class="uk-visible@m").uk-margin.uk-text-small.uk-text-muted
p One image will be captured from this live preview every 10 minutes. It will be uploaded and stored in the work session with a timestamp.
p When you start a work session, you will select the screen, application, or browser tab to share.
block viewjs
script.
window.dtp = window.dtp || { };
window.dtp.task = !{JSON.stringify(task)};

4
app/views/user/settings.pug

@ -68,8 +68,8 @@ block view-content
.uk-margin
label(for="ui-theme").uk-form-label UI Theme
select(id="ui-theme", name="uiTheme").uk-select
option(value="tracker-light", selected= (user.ui.theme === 'tracker-light')) Light
option(value="tracker-dark", selected= (user.ui.theme === 'tracker-dark')) Dark
option(value="dtp-light", selected= (user.ui.theme === 'dtp-light')) Light
option(value="dtp-dark", selected= (user.ui.theme === 'dtp-dark')) Dark
li
.uk-margin

7
app/views/welcome/home.pug

@ -24,9 +24,6 @@ block view-content
h2 About #{site.name}
p #{site.name} is a real-time communications tool with high quality audio and video, extremely low latency, instant messaging, voicemail, and an easy-to-use interface. It removes and avoids everything unnecessary while focusing on being excellent at making calls and helping people stay in touch.
p #{site.name} provides an application development harness for building secure and scalable production-ready web applications.
p There is no app to download from a store. #{site.name} is a Progressive Web App (PWA) that runs in your browser. It can be installed as a desktop or mobile app if you like, and provides an even sleeker interface if you do. It can do a better job delivering notifications when installed as an app.
.uk-margin
.uk-text-small.uk-text-muted Anonymous use is not supported. A user account in good standing is required to use the app. #{site.name} is not free for hosting group and conference calls. Free members can make and receive calls, but can't create group/conference calls and don't have voicemail services.
p There is no app to download from a store. #{site.name} is a Progressive Web App (PWA) that runs in your browser. It can be installed as a desktop or mobile app if you like, and provides a more native interface. #{site.name} can do a better job delivering notifications when installed as an app.

107
app/workers/tracker-monitor.js

@ -1,107 +0,0 @@
// 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,
);
await this.expireTaskSessions();
/*
* 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));
this.log.info('Tracker Monitor online');
}
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');
this.log.debug('scanning for defunct sessions');
await this.TaskSession
.find({
$and: [
{ status: { $in: ['active', 'reconnecting'] } },
{ 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);
}
})();

BIN
assets/icon/dtp-chat.app-icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-114x114.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-120x120.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-144x144.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-150x150.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-152x152.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-16x16.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 B

BIN
assets/icon/dtp-chat.app-icon/icon-180x180.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-192x192.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-256x256.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-310x310.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-32x32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-36x36.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-384x384.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-48x48.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-512x512.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-57x57.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-60x60.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-70x70.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-72x72.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-76x76.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-96x96.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

BIN
client/img/app-icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 29 KiB

BIN
client/img/icon/icon-114x114.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
client/img/icon/icon-120x120.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
client/img/icon/icon-144x144.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
client/img/icon/icon-150x150.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
client/img/icon/icon-152x152.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
client/img/icon/icon-16x16.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 B

After

Width:  |  Height:  |  Size: 767 B

BIN
client/img/icon/icon-180x180.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
client/img/icon/icon-192x192.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
client/img/icon/icon-256x256.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
client/img/icon/icon-310x310.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
client/img/icon/icon-32x32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
client/img/icon/icon-36x36.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
client/img/icon/icon-384x384.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 37 KiB

BIN
client/img/icon/icon-48x48.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
client/img/icon/icon-512x512.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 60 KiB

BIN
client/img/icon/icon-57x57.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
client/img/icon/icon-60x60.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
client/img/icon/icon-70x70.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
client/img/icon/icon-72x72.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
client/img/icon/icon-76x76.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
client/img/icon/icon-96x96.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
client/img/nav-icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

8
client/js/time-tracker-audio.js → client/js/base-audio.js

@ -1,19 +1,17 @@
// time-tracker-audio.js
// base-audio.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'TimeTrackerAudio';
import DtpLog from 'lib/dtp-log';
const AudioContext = window.AudioContext || window.webkitAudioContext;
export default class TimeTrackerAudio {
export default class BaseAudio {
constructor ( ) {
this.log = new DtpLog(DTP_COMPONENT_NAME);
this.log = new DtpLog('BaseAudio');
}
start ( ) {

340
client/js/base-client.js

@ -0,0 +1,340 @@
// base-client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const dtp = window.dtp = window.dtp || { };
import DtpApp from 'lib/dtp-app.js';
import BaseAudio from './base-audio.js';
import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(dayjsRelativeTime);
export class BaseApp extends DtpApp {
static get SFX_SAMPLE_SOUND ( ) { return 'sample-sound'; }
constructor (user) {
super('BaseApp', user);
this.loadSettings();
this.notificationPermission = 'default';
this.haveFocus = true; // hard to load the app w/o also being the focused app
window.addEventListener('dtp-load', this.onDtpLoad.bind(this));
window.addEventListener('focus', this.onWindowFocus.bind(this));
window.addEventListener('blur', this.onWindowBlur.bind(this));
/*
* Page Visibility API hooks
*/
window.addEventListener('pageshow', this.onWindowPageShow.bind(this));
window.addEventListener('pagehide', this.onWindowPageHide.bind(this));
window.addEventListener('freeze', this.onWindowFreeze.bind(this));
window.addEventListener('resume', this.onWindowResume.bind(this));
this.updateTimestamps();
this.log.info('constructor', 'BaseApp client online');
}
async onDtpLoad ( ) {
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.');
await this.connect({
mode: 'User',
onSocketConnect: this.onBaseSocketConnect.bind(this),
onSocketDisconnect: this.onBaseSocketDisconnect.bind(this),
});
}
async onWindowFocus (event) {
this.log.debug('onWindowFocus', 'window has received focus', { event });
this.haveFocus = true;
}
async onWindowBlur (event) {
this.log.debug('onWindowBlur', 'window has lost focus', { event });
this.haveFocus = false;
}
async onWindowPageShow (event) {
this.log.debug('onWindowPageShow', 'the page is being shown', { event });
}
async onWindowPageHide (event) {
this.log.debug('onWindowPageHide', 'the page is being hidden', { event });
if (!event.persisted) {
await this.socket.disconnect();
}
}
async onWindowFreeze (event) {
this.log.debug('onWindowFreeze', 'the page is being frozen', { event });
}
async onWindowResume (event) {
this.log.debug('onWindowResume', 'the page is being resumed', { event });
}
async startAudio ( ) {
this.log.info('startAudio', 'starting audio');
this.audio = new BaseAudio();
this.audio.start();
try {
await Promise.all([
this.audio.loadSound(BaseApp.SFX_SAMPLE_SOUND, '/static/sfx/sample-sound.mp3'),
]);
} catch (error) {
this.log.error('startAudio', 'failed to load sound', { error });
// fall through
}
}
async onBaseSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events');
this.systemMessageHandler = this.onSystemMessage.bind(this);
socket.on('system-message', this.systemMessageHandler);
}
async onBaseSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events');
socket.off('system-message', this.systemMessageHandler);
delete this.systemMessageHandler;
if (this.taskSession) {
await this.setTaskSessionStatus('reconnecting');
}
}
async onSystemMessage (message) {
if (message.displayList) {
this.displayEngine.executeDisplayList(message.displayList);
}
}
async confirmNavigation (event) {
const target = event.currentTarget || event.target;
event.preventDefault();
event.stopPropagation();
const href = target.getAttribute('href');
const hrefTarget = target.getAttribute('target');
const text = target.textContent;
const whitelist = [
'digitaltelepresence.com',
'www.digitaltelepresence.com',
];
try {
const url = new URL(href);
if (!whitelist.includes(url.hostname)) {
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`);
}
window.open(href, hrefTarget);
} catch (error) {
this.log.info('confirmNavigation', 'navigation canceled', { error });
}
return true;
}
async generateOtpQR (canvas, keyURI) {
QRCode.toCanvas(canvas, keyURI);
}
async generateQRCanvas (canvas, uri) {
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri });
QRCode.toCanvas(canvas, uri, { width: 256 });
}
async closeAllDropdowns ( ) {
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open');
for (const dropdown of dropdowns) {
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown });
UIkit.dropdown(dropdown).hide(false);
}
}
async initSettingsView ( ) {
this.log.info('initSettingsView', 'settings', { settings: this.settings });
}
loadSettings ( ) {
this.settings = { };
if (window.localStorage) {
if (window.localStorage.settings) {
this.settings = JSON.parse(window.localStorage.settings);
} else {
this.saveSettings();
}
}
}
saveSettings ( ) {
if (!window.localStorage) { return; }
window.localStorage.settings = JSON.stringify(this.settings);
}
async submitImageForm (event) {
event.preventDefault();
event.stopPropagation();
const formElement = event.currentTarget || event.target;
const form = new FormData(formElement);
this.cropper.getCroppedCanvas().toBlob(async (imageData) => {
try {
const imageId = formElement.getAttribute('data-image-id');
form.append('imageFile', imageData, imageId);
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action });
const response = await fetch(formElement.action, {
method: formElement.method,
body: form,
});
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to upload image: ${error.message}`);
}
});
return;
}
async selectImageFile (event) {
event.preventDefault();
const imageId = event.target.getAttribute('data-image-id');
let cropperOptions = event.target.getAttribute('data-cropper-options');
if (cropperOptions) {
cropperOptions = JSON.parse(cropperOptions);
}
const fileSelectContainerId = event.target.getAttribute('data-file-select-container');
if (!fileSelectContainerId) {
UIkit.modal.alert('Missing file select container element ID information');
return;
}
const fileSelectContainer = document.getElementById(fileSelectContainerId);
if (!fileSelectContainer) {
UIkit.modal.alert('Missing file select element');
return;
}
const fileSelect = fileSelectContainer.querySelector('input[type="file"]');
if (!fileSelect.files || (fileSelect.files.length === 0)) {
return;
}
const selectedFile = fileSelect.files[0];
if (!selectedFile) {
return;
}
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile });
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i;
if (!filter.test(selectedFile.type)) {
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`);
return;
}
const fileSizeId = event.target.getAttribute('data-file-size-element');
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10);
const fileSize = document.getElementById(fileSizeId);
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b');
if (selectedFile.size > (FILE_MAX_SIZE)) {
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const img = document.getElementById(imageId);
img.onload = (e) => {
console.log('image loaded', e, img.naturalWidth, img.naturalHeight);
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name;
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow();
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString();
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString();
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true);
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden');
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden');
};
img.src = e.target.result;
this.createImageCropper(img, cropperOptions);
};
reader.readAsDataURL(selectedFile);
}
async createImageCropper (img, options) {
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
options = Object.assign({
aspectRatio: 1,
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
dragMode: 'move',
autoCropArea: 0.85,
restore: false,
guides: false,
center: false,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
modal: true,
}, options);
this.log.info("createImageCropper", "Creating image cropper", { img });
this.cropper = new Cropper(img, options);
}
async removeImageFile (event) {
const target = event.target || event.currentTarget;
const imageType = target.getAttribute('data-image-type');
try {
this.log.info('removeImageFile', 'request to remove image', event);
let imageUrl;
switch (imageType) {
case 'profile-picture-file':
imageUrl = `/user/${this.user._id}/profile-photo`;
break;
default:
throw new Error(`Invalid image type: ${imageType}`);
}
const response = await fetch(imageUrl, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Server error');
}
await this.processResponse(response);
} catch (error) {
this.log.error('removeImageFile', 'failed to remove image', { error });
UIkit.modal.alert(`Failed to remove image: ${error.message}`);
}
}
async onWindowResize ( ) {
if (this.chat.messageList && this.chat.isAtBottom) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000);
}
}
}

4
client/js/index.js

@ -7,14 +7,14 @@
const DTP_COMPONENT_NAME = 'DtpChat';
const dtp = window.dtp = window.dtp || { };
import { TimeTrackerApp } from './time-tracker-client.js';
import { BaseApp } from './base-client.js';
import DtpWebLog from 'lib/dtp-log.js';
window.addEventListener('load', async ( ) => {
dtp.log = new DtpWebLog(DTP_COMPONENT_NAME);
dtp.env = document.body.getAttribute('data-dtp-env');
dtp.app = new TimeTrackerApp(dtp.user);
dtp.app = new BaseApp(dtp.user);
dtp.log.info('load handler', 'application instance created', { env: dtp.env });
await dtp.app.startAudio();

652
client/js/time-tracker-client.js

@ -1,652 +0,0 @@
// time-tracker-client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'TimeTrackerApp';
const dtp = window.dtp = window.dtp || { };
import DtpApp from 'lib/dtp-app.js';
import TimeTrackerAudio from './time-tracker-audio.js';
import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js';
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'; }
constructor (user) {
super(DTP_COMPONENT_NAME, user);
this.loadSettings();
this.log.info('constructor', 'TimeTrackerApp client online');
this.notificationPermission = 'default';
this.haveFocus = true; // hard to load the app w/o also being the focused app
this.capturePreview = document.querySelector('video#capture-preview');
this.dragFeedback = document.querySelector('.dtp-drop-feedback');
this.currentSessionStartTime = null;
this.currentSessionDuration = document.querySelector('#current-session-duration');
this.currentSessionTimeRemaining = document.querySelector('#time-remaining');
window.addEventListener('dtp-load', this.onDtpLoad.bind(this));
window.addEventListener('focus', this.onWindowFocus.bind(this));
window.addEventListener('blur', this.onWindowBlur.bind(this));
/*
* Page Visibility API hooks
*/
window.addEventListener('pageshow', this.onWindowPageShow.bind(this));
window.addEventListener('pagehide', this.onWindowPageHide.bind(this));
window.addEventListener('freeze', this.onWindowFreeze.bind(this));
window.addEventListener('resume', this.onWindowResume.bind(this));
this.updateTimestamps();
}
async onDtpLoad ( ) {
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.');
await this.connect({
mode: 'User',
onSocketConnect: this.onTrackerSocketConnect.bind(this),
onSocketDisconnect: this.onTrackerSocketDisconnect.bind(this),
});
}
async onWindowFocus (event) {
this.log.debug('onWindowFocus', 'window has received focus', { event });
this.haveFocus = true;
}
async onWindowBlur (event) {
this.log.debug('onWindowBlur', 'window has lost focus', { event });
this.haveFocus = false;
}
async onWindowPageShow (event) {
this.log.debug('onWindowPageShow', 'the page is being shown', { event });
}
async onWindowPageHide (event) {
this.log.debug('onWindowPageHide', 'the page is being hidden', { event });
if (!event.persisted) {
await this.socket.disconnect();
}
}
async onWindowFreeze (event) {
this.log.debug('onWindowFreeze', 'the page is being frozen', { event });
}
async onWindowResume (event) {
this.log.debug('onWindowResume', 'the page is being resumed', { event });
}
async startAudio ( ) {
this.log.info('startAudio', 'starting audio');
this.audio = new TimeTrackerAudio();
this.audio.start();
try {
await Promise.all([
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_START, '/static/sfx/tracker-start.mp3'),
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_UPDATE, '/static/sfx/tracker-update.mp3'),
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_STOP, '/static/sfx/tracker-stop.mp3'),
]);
} catch (error) {
this.log.error('startAudio', 'failed to load sound', { error });
// fall through
}
}
async onDragEnter (event) {
event.preventDefault();
event.stopPropagation();
this.log.info('onDragEnter', 'something being dragged has entered the stage', { event });
this.dragFeedback.classList.add('feedback-active');
}
async onDragLeave (event) {
event.preventDefault();
event.stopPropagation();
this.log.info('onDragLeave', 'something being dragged has left the stage', { event });
this.dragFeedback.classList.remove('feedback-active');
}
async onDragOver (event) {
/*
* Inform that we want "copy" as a drop effect and prevent all default
* processing so we'll actually get the files in the drop event. If this
* isn't done, you simply won't get the files in the drop.
*/
event.preventDefault();
event.stopPropagation(); // this ends now!
event.dataTransfer.dropEffect = 'copy';
// this.log.info('onDragOver', 'something was dragged over the stage', { event });
this.dragFeedback.classList.add('feedback-active');
}
async onDrop (event) {
event.preventDefault();
event.stopPropagation();
for (const file of event.dataTransfer.files) {
this.log.info('onDrop', 'a file has been dropped', { file });
}
this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files });
this.dragFeedback.classList.remove('feedback-active');
}
async onTrackerSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events');
this.systemMessageHandler = this.onSystemMessage.bind(this);
socket.on('system-message', this.systemMessageHandler);
this.sessionControlHandler = this.onSessionControl.bind(this);
socket.on('session-control', this.sessionControlHandler);
if (dtp.task) {
await this.socket.joinChannel(dtp.task._id, 'Task');
}
if (this.taskSession) {
await this.setTaskSessionStatus('active');
}
}
async onTrackerSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events');
socket.off('session-control', this.sessionControlHandler);
delete this.sessionControlHandler;
socket.off('system-message', this.systemMessageHandler);
delete this.systemMessageHandler;
if (this.taskSession) {
await this.setTaskSessionStatus('reconnecting');
}
}
async onSystemMessage (message) {
if (message.displayList) {
this.displayEngine.executeDisplayList(message.displayList);
}
}
async onSessionControl (message) {
const activityToggle = document.querySelector('');
if (message.cmd) {
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();
event.stopPropagation();
const href = target.getAttribute('href');
const hrefTarget = target.getAttribute('target');
const text = target.textContent;
const whitelist = [
'chat.digitaltelepresence.com',
'digitaltelepresence.com',
'sites.digitaltelepresence.com',
'tracker.digitaltelepresence.com',
'www.digitaltelepresence.com',
];
try {
const url = new URL(href);
if (!whitelist.includes(url.hostname)) {
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`);
}
window.open(href, hrefTarget);
} catch (error) {
this.log.info('confirmNavigation', 'navigation canceled', { error });
}
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);
}
async generateQRCanvas (canvas, uri) {
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri });
QRCode.toCanvas(canvas, uri, { width: 256 });
}
async closeAllDropdowns ( ) {
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open');
for (const dropdown of dropdowns) {
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown });
UIkit.dropdown(dropdown).hide(false);
}
}
async initSettingsView ( ) {
this.log.info('initSettingsView', 'settings', { settings: this.settings });
}
loadSettings ( ) {
this.settings = { };
if (window.localStorage) {
if (window.localStorage.settings) {
this.settings = JSON.parse(window.localStorage.settings);
} else {
this.saveSettings();
}
}
}
saveSettings ( ) {
if (!window.localStorage) { return; }
window.localStorage.settings = JSON.stringify(this.settings);
}
async submitImageForm (event) {
event.preventDefault();
event.stopPropagation();
const formElement = event.currentTarget || event.target;
const form = new FormData(formElement);
this.cropper.getCroppedCanvas().toBlob(async (imageData) => {
try {
const imageId = formElement.getAttribute('data-image-id');
form.append('imageFile', imageData, imageId);
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action });
const response = await fetch(formElement.action, {
method: formElement.method,
body: form,
});
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to upload image: ${error.message}`);
}
});
return;
}
async selectImageFile (event) {
event.preventDefault();
const imageId = event.target.getAttribute('data-image-id');
//z read the cropper options from the element on the page
let cropperOptions = event.target.getAttribute('data-cropper-options');
if (cropperOptions) {
cropperOptions = JSON.parse(cropperOptions);
}
this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done
const fileSelectContainerId = event.target.getAttribute('data-file-select-container');
if (!fileSelectContainerId) {
UIkit.modal.alert('Missing file select container element ID information');
return;
}
const fileSelectContainer = document.getElementById(fileSelectContainerId);
if (!fileSelectContainer) {
UIkit.modal.alert('Missing file select element');
return;
}
const fileSelect = fileSelectContainer.querySelector('input[type="file"]');
if (!fileSelect.files || (fileSelect.files.length === 0)) {
return;
}
const selectedFile = fileSelect.files[0];
if (!selectedFile) {
return;
}
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile });
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i;
if (!filter.test(selectedFile.type)) {
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`);
return;
}
const fileSizeId = event.target.getAttribute('data-file-size-element');
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10);
const fileSize = document.getElementById(fileSizeId);
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b');
if (selectedFile.size > (FILE_MAX_SIZE)) {
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`);
return;
}
// const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w'));
// const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h'));
const reader = new FileReader();
reader.onload = (e) => {
const img = document.getElementById(imageId);
img.onload = (e) => {
console.log('image loaded', e, img.naturalWidth, img.naturalHeight);
// if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) {
// UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`);
// img.setAttribute('hidden', '');
// img.src = '';
// return;
// }
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name;
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow();
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString();
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString();
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true);
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden');
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden');
};
// set the image as the "src" of the <img> in the DOM.
img.src = e.target.result;
//z create cropper and set options here
this.createImageCropper(img, cropperOptions);
};
// read in the file, which will trigger everything else in the event handler above.
reader.readAsDataURL(selectedFile);
}
async createImageCropper (img, options) {
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
options = Object.assign({
aspectRatio: 1,
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
dragMode: 'move',
autoCropArea: 0.85,
restore: false,
guides: false,
center: false,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
modal: true,
}, options);
this.log.info("createImageCropper", "Creating image cropper", { img });
this.cropper = new Cropper(img, options);
}
async removeImageFile (event) {
const target = event.target || event.currentTarget;
const imageType = target.getAttribute('data-image-type');
const channelId = dtp.channel ? dtp.channel._id : dtp.channel;
try {
this.log.info('removeImageFile', 'request to remove image', event);
let imageUrl;
switch (imageType) {
case 'channel-thumbnail-file':
imageUrl = `/channel/${channelId}/thumbnail`;
break;
case 'profile-picture-file':
imageUrl = `/user/${this.user._id}/profile-photo`;
break;
default:
throw new Error(`Invalid image type: ${imageType}`);
}
const response = await fetch(imageUrl, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Server error');
}
await this.processResponse(response);
} catch (error) {
this.log.error('removeImageFile', 'failed to remove image', { error });
UIkit.modal.alert(`Failed to remove image: ${error.message}`);
}
}
async onWindowResize ( ) {
if (this.chat.messageList && this.chat.isAtBottom) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000);
}
}
async taskActivityToggle (event) {
const target = event.currentTarget || event.target;
event.preventDefault();
event.stopPropagation();
try {
if (target.checked) {
await this.startScreenCapture();
return this.startTaskSession();
}
await this.stopScreenCapture();
this.closeTaskSession();
} catch (error) {
if (target.checked) {
target.checked = false;
}
this.log.error('taskActivityToggle', 'failed to start task work session', { error });
UIkit.modal.alert(`Failed to start work session: ${error.message}`);
}
}
async startTaskSession ( ) {
try {
const url = `/task/${dtp.task._id}/session/start`;
const response = await fetch(url, { method: 'POST' });
await this.checkResponse(response);
const json = await response.json();
if (!json.success) {
throw new Error(json.message);
}
this.taskSession = json.session;
this.currentSessionStartTime = new Date();
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');
this.log.info('startTaskSession', 'task session started', { session: this.taskSession });
} catch (error) {
this.log.error('startTaskSession', 'failed to start task session', { error });
UIkit.modal.alert(`Failed to start task session: ${error.message}`);
throw new Error('failed to start task session', { cause: error });
}
}
async setTaskSessionStatus (status) {
try {
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/status`;
const body = JSON.stringify({ status });
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length,
},
body,
});
await this.processResponse(response);
this.taskSession.status = status;
} catch (error) {
UIkit.notification({
message: `Failed to update task session status: ${error.message}`,
status: 'danger',
pos: 'bottom-center',
timeout: 5000,
});
}
}
async closeTaskSession ( ) {
try {
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`;
const response = await fetch(url , { method: 'POST' });
await this.processResponse(response);
clearInterval(this.sessionDisplayUpdateInterval);
delete this.sessionDisplayUpdateInterval;
clearInterval(this.screenshotInterval);
delete this.screenshotInterval;
this.currentSessionDuration.classList.remove('uk-text-success');
delete this.currentSessionStartTime;
} catch (error) {
this.log.error('closeTaskSession', 'failed to close task session', {
session: this.taskSession,
error,
});
UIkit.modal.alert(`Failed to start task session: ${error.message}`);
throw new Error('failed to close task session', { cause: error });
}
}
async startScreenCapture ( ) {
this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
this.capturePreview.srcObject = this.captureStream;
this.capturePreview.play();
}
async stopScreenCapture ( ) {
if (!this.captureStream) {
return;
}
this.capturePreview.pause();
this.capturePreview.srcObject = null;
this.captureStream.getTracks().forEach(track => track.stop());
delete this.captureStream;
}
async updateSessionDisplay ( ) {
if (this.taskSession.status === 'reconnecting') {
this.currentSessionDuration.textContent = '---';
this.currentSessionTimeRemaining.textContent = '---';
return;
}
const NOW = new Date();
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second');
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS');
const timeRemaining =
this.taskSession.client.hoursLimit -
((this.taskSession.client.weeklyTotals.timeWorked + duration) / 3600)
;
this.currentSessionTimeRemaining.textContent = numeral(timeRemaining).format('0,0.00');
}
async captureScreenshot ( ) {
if (!this.captureStream || !this.taskSession) {
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
*/
captureContext.drawImage(
this.capturePreview,
0, 0,
captureCanvas.width,
captureCanvas.height,
);
/*
* Generate a PNG Blob from the capture canvas
*/
captureCanvas.toBlob(
async (blob) => {
const formData = new FormData();
formData.append('image', blob, 'screenshot.png');
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/screenshot`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
await this.processResponse(response);
this.log.info('captureScreenshot', 'screenshot posted to task session');
},
'image/png',
);
} catch (error) {
this.log.error('captureScreenshot', 'failed to capture screenshot', { error });
}
}
}

0
client/static/sfx/tracker-start.mp3 → client/static/sfx/sample-sound.mp3

BIN
client/static/sfx/tracker-stop.mp3

Binary file not shown.

BIN
client/static/sfx/tracker-update.mp3

Binary file not shown.

107
config/limiter.js

@ -67,62 +67,6 @@ export default {
},
},
/*
* ClientController
*/
client: {
postProjectUpdate: {
total: 100,
expire: ONE_HOUR,
message: "You are updating projects too quickly",
},
postProjectCreate: {
total: 10,
expire: ONE_HOUR,
message: "You are creating projects too quickly",
},
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",
},
getProjectCreate: {
total: 30,
expire: ONE_HOUR,
message: "You are creating projects too quickly",
},
getProjectEditor: {
total: 250,
expire: ONE_HOUR,
message: "You are editing projects too quickly",
},
getProjectView: {
total: 250,
expire: ONE_HOUR,
message: "You are viewing projects too quickly",
},
getClientEditor: {
total: 100,
expire: ONE_HOUR,
message: "You are creating or editing clients too quickly",
},
getClientView: {
total: 250,
expire: ONE_HOUR,
message: "You are viewing clients too quickly",
},
getHome: {
total: 250,
expire: ONE_HOUR,
message: "You are accessing the clients dashboard too quickly",
},
},
/*
* EmailController
*/
@ -192,57 +136,6 @@ export default {
}
},
/*
* TaskController
*/
task: {
postStartTaskSession: {
total: 12,
expire: ONE_HOUR,
message: 'You are starting task work sessions too quickly',
},
postTaskSessionScreenshot: {
total: 20,
expire: ONE_HOUR,
message: 'You are uploading session screenshots too quickly',
},
postTaskSessionStatus: {
total: 100,
expire: ONE_HOUR,
message: 'You are changing task work session status too quickly',
},
postCloseTaskSession: {
total: 12,
expire: ONE_HOUR,
message: 'You are closing work sessions too quickly',
},
postStartTask: {
total: 60,
expire: ONE_HOUR,
message: 'You are starting tasks too quickly',
},
postCloseTask: {
total: 60,
expire: ONE_HOUR,
message: 'You are closing tasks too quickly',
},
postCreateTask: {
total: 60,
expire: ONE_HOUR,
message: 'You are creating tasks too quickly',
},
getTaskSessionView: {
total: 20,
expire: ONE_MINUTE,
message: 'You are opening sessions too quickly',
},
getTaskView: {
total: 20,
expire: ONE_MINUTE,
message: 'You are opening tasks too quickly',
},
},
/*
* UserController
*/

5
config/reserved-names.js

@ -1,5 +1,5 @@
// reserved-names.js
// Copyright (C) 2022,2023 DTP Technologies, LLC
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
@ -7,12 +7,9 @@
export default [
'admin',
'auth',
'client',
'email',
'image',
'manifest',
'report',
'task',
'user',
'video',
'welcome',

35
data/patches/client-stats.js

@ -1,35 +0,0 @@
'use strict';
/* globals db */
const clients = db.clients.find();
while (clients.hasNext()) {
let timeWorked = 0, billable = 0;
const client = clients.next();
const sessions = db.tasksessions.find({ client: client._id });
while (sessions.hasNext()) {
const session = sessions.next();
timeWorked += session.duration;
billable += session.hourlyRate * (session.duration / 3600);
}
/*
* Fix some JavaScript goofiness with numbers (round correctly to 2nd decimal
* digit).
*/
billable = Math.round((billable + Number.EPSILON) * 100) / 100;
print(`client: ${client._id}:${client.name} time:${timeWorked} bill:${billable}`);
db.clients.updateOne(
{ _id: client._id },
{
$set: {
weeklyTotals: {
timeWorked,
billable,
},
},
},
);
}

55
dtp-time-tracker-cli.js → dtp-base-cli.js

@ -1,4 +1,4 @@
// dtp-time-tracker-cli.js
// dtp-base-cli.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
@ -48,18 +48,6 @@ class SiteTerminalApp extends SiteRuntime {
handler: this.revoke.bind(this),
help: 'revoke [admin|moderator] username',
},
'probe': {
handler: this.probeMediaFile.bind(this),
help: 'probe filename',
},
'transcode-mov': {
handler: this.transcodeMov.bind(this),
help: 'transcode-mov filename',
},
'transcode-gif': {
handler: this.transcodeGif.bind(this),
help: 'transcode-gif filename',
},
};
}
@ -152,45 +140,6 @@ class SiteTerminalApp extends SiteRuntime {
break;
}
}
async probeMediaFile (args) {
const { media: mediaService } = this.services;
const filename = args.shift();
const probe = await mediaService.ffprobe(filename);
this.log.info('FFPROBE result', { probe });
}
async transcodeMov (args) {
const { video: videoService } = this.services;
const filename = args.shift();
const stat = await fs.promises.stat(filename);
const file = {
path: filename,
size: stat.size,
type: 'video/quicktime',
};
await videoService.transcodeMov(file);
this.log.info('transcode output ready', { filename: file.path });
}
async transcodeGif (args) {
const { video: videoService } = this.services;
const filename = args.shift();
const stat = await fs.promises.stat(filename);
const file = {
path: filename,
size: stat.size,
type: 'image/gif',
};
await videoService.transcodeGif(file);
this.log.info('transcode output ready', { filename: file.path });
}
}
(async ( ) => {
@ -201,7 +150,7 @@ class SiteTerminalApp extends SiteRuntime {
await app.start();
await app.run(process.argv.slice(2));
} catch (error) {
console.error('failed to start Time Tracker terminal application', error);
console.error('failed to start terminal interface', error);
} finally {
await app.shutdown();
process.nextTick(( ) => {

2
dtp-time-tracker.js → dtp-base.js

@ -1,4 +1,4 @@
// dtp-chat.js
// dtp-base.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved

3
lib/site-lib.js

@ -1,5 +1,5 @@
// site-lib.js
// Copyright (C) 2022,2024 DTP Technologies, LLC
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
@ -11,3 +11,4 @@ export { SiteLog } from './site-log.js';
export { SiteController } from './site-controller.js';
export { SiteService } from './site-service.js';
export { SiteRuntime } from './site-runtime.js';
export { SiteUnzalgo } from './site-unzalgo.js';

119
lib/site-unzalgo.js

@ -0,0 +1,119 @@
// site-unzalgo.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import { percentile } from "stats-lite";
const categories = /[\p{Mn}\p{Me}]+/u;
const DEFAULT_DETECTION_THRESHOLD = 0.55;
const DEFAULT_TARGET_DENSITY = 0;
const compose = string => string.normalize("NFC");
const decompose = string => string.normalize("NFD");
const computeZalgoDensity = string => [...string].filter(character => categories.test(character)).length / Math.max(string.length, 1);
const clamp = x => Math.max(Math.min(x, 1), 0);
export class SiteUnzalgo {
/**
* Computes a score [0, 1] for every word in the input string. Each score represents the ratio of combining characters to total characters in a word.
* @param {string} string
* The input string for which to compute scores.
* @return {number[]}
* An array of scores where each score describes the Zalgo ratio of a word.
*/
static computeScores (string) {
const wordScores = [];
/**
* Trimming here allows us to return early.
* Without trimming, we risk dividing by `0` later when computing the score.
*/
if (!string.trim().length) {
wordScores.push(0);
}
else {
for (const word of decompose(string).split(/\s+/)) {
let banned = 0;
for (const character of word) {
if (categories.test(character)) {
++banned;
}
}
const score = banned / word.length;
wordScores.push(score);
}
}
return wordScores;
}
/**
* Determines if the string consists of Zalgo text. Note that the occurrence of a combining character is not enough to trigger the detection. Instead, it computes a ratio for the input string and checks if it exceeds a given threshold. Thus, internationalized strings aren't automatically classified as Zalgo text.
* @param {string} string
* A string for which a Zalgo text check is run.
* @param {number} detectionThreshold
* A threshold [0, 1]. The higher the threshold, the more combining characters are needed for it to be detected as Zalgo text.
* @return {boolean}
* Whether the string is a Zalgo text string.
*/
static isZalgo (string, detectionThreshold = DEFAULT_DETECTION_THRESHOLD) {
const wordScores = SiteUnzalgo.computeScores(string);
const totalScore = percentile(wordScores, 0.75);
return totalScore >= clamp(detectionThreshold);
}
/**
* Removes all combining characters for every word in a string if the word is classified as Zalgo text.
* If `targetDensity` is specified, not all the Zalgo characters will be removed. Instead, they will be thinned out uniformly.
* @param {string} string
* A string for which combining characters are removed for every word whose Zalgo property is met.
* @param {object} options
* Options for cleaning.
* @param {number} [options.detectionThreshold=DEFAULT_DETECTION_THRESHOLD]
* A threshold [0, 1]. The higher the threshold, the more combining characters are needed for it to be detected as Zalgo text.
* @param {number} [options.targetDensity=DEFAULT_TARGET_DENSITY]
* A threshold [0, 1]. The higher the density, the more Zalgo characters will be part of the resulting string. The result is guaranteed to have a Zalgo-character density that is less than or equal to the one provided.
* @return {string}
* A cleaned, more readable string.
*/
static clean (
string,
{
detectionThreshold = DEFAULT_DETECTION_THRESHOLD,
targetDensity = DEFAULT_TARGET_DENSITY
} = { },
) {
let cleaned = "";
const effectiveTargetDensity = clamp(targetDensity);
for (const word of decompose(string).split(/(\s+)/)) {
if (SiteUnzalgo.isZalgo(word, detectionThreshold)) {
let cleanedWord = "";
const letters = [...word].map(character => ({
character,
isCandidate: categories.test(character)
}));
for (let i = 0; i < letters.length; ++i) {
const {
character,
isCandidate
} = letters[i];
if (isCandidate) {
const admissionProjection = cleanedWord + word.substr(i);
const omissionProjection = cleanedWord + word.substr(i + 1);
const admissionDistance = effectiveTargetDensity - computeZalgoDensity(admissionProjection);
const omissionDistance = effectiveTargetDensity - computeZalgoDensity(omissionProjection);
if (Math.abs(omissionDistance) <= Math.abs(admissionDistance)) {
continue;
}
}
cleanedWord += character;
}
cleaned += cleanedWord;
}
else {
cleaned += word;
}
}
return compose(cleaned);
}
}

13
package.json

@ -1,14 +1,14 @@
{
"name": "dtp-time-tracker",
"name": "dtp-base",
"type": "module",
"version": "1.0.21",
"version": "0.0.1",
"description": "",
"main": "dtp-time-tracker.js",
"main": "dtp-base.js",
"scripts": {
"dev": "nodemon dtp-time-tracker.js",
"dev": "nodemon dtp-base.js",
"build": "NODE_ENV=production pnpm webpack --config webpack.config.js"
},
"repository": "[email protected]:digital-telepresence/dtp-time-tracker.git",
"repository": "[email protected]:digital-telepresence/dtp-base.git",
"author": "Rob Colbert",
"license": "LicenseRef-LICENSE",
"private": true,
@ -38,7 +38,6 @@
"ioredis": "^5.3.2",
"jsdom": "^24.0.0",
"marked": "^12.0.1",
"mediasoup": "^3.13.24",
"mime": "^4.0.1",
"minio": "^7.1.3",
"mongoose": "^8.3.1",
@ -59,11 +58,11 @@
"shoetest": "^1.2.2",
"slug": "^9.0.0",
"socket.io": "^4.7.5",
"stats-lite": "^2.2.0",
"striptags": "^3.2.0",
"svg-captcha": "^1.4.0",
"systeminformation": "^5.22.7",
"uikit": "^3.19.4",
"unzalgo": "^3.0.0",
"user-agents": "^1.1.174",
"uuid": "^9.0.1"
},

192
pnpm-lock.yaml

@ -80,9 +80,6 @@ dependencies:
marked:
specifier: ^12.0.1
version: 12.0.1
mediasoup:
specifier: ^3.13.24
version: 3.14.1
mime:
specifier: ^4.0.1
version: 4.0.1
@ -143,6 +140,9 @@ dependencies:
socket.io:
specifier: ^4.7.5
version: 4.7.5
stats-lite:
specifier: ^2.2.0
version: 2.2.0
striptags:
specifier: ^3.2.0
version: 3.2.0
@ -155,9 +155,6 @@ dependencies:
uikit:
specifier: ^3.19.4
version: 3.19.4
unzalgo:
specifier: ^3.0.0
version: 3.0.0
user-agents:
specifier: ^1.1.174
version: 1.1.178
@ -262,7 +259,7 @@ packages:
'@babel/traverse': 7.24.1
'@babel/types': 7.24.0
convert-source-map: 2.0.0
debug: 4.3.4([email protected])
debug: 4.3.4
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -343,7 +340,7 @@ packages:
'@babel/core': 7.24.4
'@babel/helper-compilation-targets': 7.23.6
'@babel/helper-plugin-utils': 7.24.0
debug: 4.3.4([email protected])
debug: 4.3.4
lodash.debounce: 4.0.8
resolve: 1.22.8
transitivePeerDependencies:
@ -1393,7 +1390,7 @@ packages:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.24.4
'@babel/types': 7.24.0
debug: 4.3.4([email protected])
debug: 4.3.4
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -1809,7 +1806,7 @@ packages:
peerDependencies:
socket.io-adapter: ^2.5.4
dependencies:
debug: 4.3.4([email protected])
debug: 4.3.4
notepack.io: 3.0.1
socket.io-adapter: 2.5.4
uid2: 1.0.0
@ -1820,7 +1817,7 @@ packages:
/@socket.io/[email protected]:
resolution: {integrity: sha512-QQUFPBq6JX7JIuM/X1811ymKlAfwufnQ8w6G2/59Jaqp09hdF1GJ/+e8eo/XdcmT0TqkvcSa2TT98ggTXa5QYw==}
dependencies:
debug: 4.3.4([email protected])
debug: 4.3.4
notepack.io: 3.0.1
socket.io-parser: 4.2.4
transitivePeerDependencies:
@ -1844,12 +1841,6 @@ packages:
dependencies:
'@types/node': 20.12.7
/@types/[email protected]:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies:
'@types/ms': 0.7.34
dev: false
/@types/[email protected]:
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
dependencies:
@ -1872,10 +1863,6 @@ packages:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
dev: true
/@types/[email protected]:
resolution: {integrity: sha512-mTehMtc+xtnWBBvqizcqYCktKDBH2WChvx1GU3Sfe4PysFDXiNe+1YwtpVX1MDtCa4NQrSPw2+3HmvXHY3gt1w==}
dev: false
/@types/[email protected]:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: true
@ -1884,10 +1871,6 @@ packages:
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
dev: false
/@types/[email protected]:
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
dev: false
/@types/[email protected]:
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==}
dependencies:
@ -2111,7 +2094,7 @@ packages:
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
engines: {node: '>= 14'}
dependencies:
debug: 4.3.4([email protected])
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
@ -2607,11 +2590,6 @@ packages:
fsevents: 2.3.3
dev: true
/[email protected]:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
dev: false
/[email protected]:
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==}
engines: {node: '>=6.0'}
@ -2936,11 +2914,6 @@ packages:
rrweb-cssom: 0.6.0
dev: false
/[email protected]:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
dev: false
/[email protected]:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@ -3006,7 +2979,7 @@ packages:
supports-color: 5.5.0
dev: true
/[email protected]([email protected]):
/[email protected]:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
@ -3016,7 +2989,6 @@ packages:
optional: true
dependencies:
ms: 2.1.2
supports-color: 9.4.0
/[email protected]:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
@ -3220,7 +3192,7 @@ packages:
resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==}
dependencies:
'@socket.io/component-emitter': 3.1.1
debug: 4.3.4([email protected])
debug: 4.3.4
engine.io-parser: 5.2.2
ws: 8.11.0
xmlhttprequest-ssl: 2.0.0
@ -3245,7 +3217,7 @@ packages:
base64id: 2.0.0
cookie: 0.4.2
cors: 2.8.5
debug: 4.3.4([email protected])
debug: 4.3.4
engine.io-parser: 5.2.2
ws: 8.11.0
transitivePeerDependencies:
@ -3540,14 +3512,6 @@ packages:
engines: {node: '>= 4.9.1'}
dev: true
/[email protected]:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
dev: false
/[email protected]:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
dependencies:
@ -3608,10 +3572,6 @@ packages:
hasBin: true
dev: true
/[email protected]:
resolution: {integrity: sha512-3HDgPbgiwWMI9zVB7VYBHaMrbOO7Gm0v+yD2FV/sCKj+9NDeVL7BOBYUuhWAQGKWOzBo8S9WdMvV0eixO233XQ==}
dev: false
/[email protected]:
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'}
@ -3636,13 +3596,6 @@ packages:
mime-types: 2.1.35
dev: false
/[email protected]:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
dependencies:
fetch-blob: 3.2.0
dev: false
/[email protected]:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@ -3670,13 +3623,6 @@ packages:
universalify: 2.0.1
dev: true
/[email protected]:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
dependencies:
minipass: 3.3.6
dev: false
/[email protected]:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
@ -3786,16 +3732,6 @@ packages:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: true
/[email protected]([email protected]):
resolution: {integrity: sha512-X4CLryVbVA0CtjTExS4G5U1gb2Z4wa32AF8ukVmFuLdw2JRq2aHisor7SY5SYTUUrUSqq0KdPIO18sql6IWIQw==}
engines: {node: '>=16'}
dependencies:
'@types/debug': 4.1.12
debug: 4.3.4([email protected])
transitivePeerDependencies:
- supports-color
dev: false
/[email protected]:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
dev: true
@ -3882,7 +3818,7 @@ packages:
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.1
debug: 4.3.4([email protected])
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
@ -3903,7 +3839,7 @@ packages:
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.1
debug: 4.3.4([email protected])
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
@ -3973,11 +3909,6 @@ packages:
/[email protected]:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
/[email protected]:
resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dev: false
/[email protected]:
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==}
engines: {node: '>= 0.4'}
@ -3998,7 +3929,7 @@ packages:
dependencies:
'@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2
debug: 4.3.4([email protected])
debug: 4.3.4
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
@ -4542,21 +4473,6 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/[email protected]:
resolution: {integrity: sha512-gzfF4HH/g2+SzDp0wgOcgxoL8d4e4+BN7jvy4ydiG+5p2ZmVTHPz4wrvYXfQNHDRlp3jtkjLoFWTPvFTWvfoTg==}
engines: {node: '>=18'}
requiresBuild: true
dependencies:
'@types/ini': 4.1.0
debug: 4.3.4([email protected])
flatbuffers: 24.3.25
h264-profile-level-id: 2.0.0([email protected])
ini: 4.1.2
node-fetch: 3.3.2
supports-color: 9.4.0
tar: 6.2.1
dev: false
/[email protected]:
resolution: {integrity: sha512-j4WKth315edViMBGkHW6NTF0QBjsTrcRDmYNcGsPq+ozMEyCCCIlX2d2mJ5wuh6iHvJ3FevUrr48v58YRqVdYg==}
engines: {node: '>= 4.0.0'}
@ -4677,26 +4593,6 @@ packages:
xml2js: 0.5.0
dev: false
/[email protected]:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
dependencies:
yallist: 4.0.0
dev: false
/[email protected]:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
dev: false
/[email protected]:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
dependencies:
minipass: 3.3.6
yallist: 4.0.0
dev: false
/[email protected]:
resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==}
dev: true
@ -4708,12 +4604,6 @@ packages:
minimist: 1.2.8
dev: false
/[email protected]:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
dev: false
/[email protected]:
resolution: {integrity: sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==}
dependencies:
@ -4797,7 +4687,7 @@ packages:
resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==}
engines: {node: '>=14.0.0'}
dependencies:
debug: 4.3.4([email protected])
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
@ -4871,20 +4761,6 @@ packages:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
dev: true
/[email protected]:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
dev: false
/[email protected]:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
dev: false
/[email protected]:
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==}
hasBin: true
@ -5915,7 +5791,7 @@ packages:
/[email protected]:
resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==}
dependencies:
debug: 4.3.4([email protected])
debug: 4.3.4
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
@ -5927,7 +5803,7 @@ packages:
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.1
debug: 4.3.4([email protected])
debug: 4.3.4
engine.io-client: 6.5.3
socket.io-parser: 4.2.4
transitivePeerDependencies:
@ -5941,7 +5817,7 @@ packages:
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.1
debug: 4.3.4([email protected])
debug: 4.3.4
transitivePeerDependencies:
- supports-color
@ -5952,7 +5828,7 @@ packages:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.5
debug: 4.3.4([email protected])
debug: 4.3.4
engine.io: 6.5.4
socket.io-adapter: 2.5.4
socket.io-parser: 4.2.4
@ -6181,10 +6057,6 @@ packages:
has-flag: 4.0.0
dev: true
/[email protected]:
resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==}
engines: {node: '>=12'}
/[email protected]:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
@ -6212,18 +6084,6 @@ packages:
engines: {node: '>=6'}
dev: true
/[email protected]:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
dev: false
/[email protected]:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@ -6503,13 +6363,6 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
/[email protected]:
resolution: {integrity: sha512-yRSDlFaYpFJK2VO0iI4I2E3l1CF8puFNL00nh7beZ/q4XSxd9XPNIlsTvfOz/fF2P6tMBLWNVLWpLBvJ9/11ZQ==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
dependencies:
stats-lite: 2.2.0
dev: false
/[email protected]:
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==}
engines: {node: '>=4'}
@ -6616,11 +6469,6 @@ packages:
'@zxing/text-encoding': 0.9.0
dev: false
/[email protected]:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
dev: false
/[email protected]:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
dev: true

12
restart

@ -1,9 +1,9 @@
#!/bin/bash
echo "Stopping production services for Time Tracker"
sudo supervisorctl stop tracker-web:*
sudo supervisorctl stop tracker-host-services:*
echo "Stopping production services for DTP Base"
sudo supervisorctl stop base-web:*
sudo supervisorctl stop base-host-services:*
echo "Starting production services for Time Tracker"
sudo supervisorctl start tracker-host-services:*
sudo supervisorctl start tracker-web:*
echo "Starting production services for DTP Base"
sudo supervisorctl start base-host-services:*
sudo supervisorctl start base-web:*

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save