DTP Base provides a scalable and secure Node.js application development harness ready for production service.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

556 lines
18 KiB

// 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 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.currentSessionBillable = document.querySelector('#current-session-billable');
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('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.onChatSocketConnect.bind(this),
onSocketDisconnect: this.onChatSocketDisconnect.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 onWindowPageHide (event) {
this.log.debug('onWindowPageHide', 'the page is being hidden', { event });
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 onChatSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events');
socket.on('system-message', this.onSystemMessage.bind(this));
if (dtp.task) {
await this.socket.joinChannel(dtp.task._id, 'Task');
}
}
async onChatSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events');
socket.off('system-message', this.onSystemMessage.bind(this));
}
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',
'chat.digitaltelepresence.com',
'sites.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');
//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), 1000 * 60 * 10);
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 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();
const tracks = this.captureStream.getVideoTracks();
const constraints = tracks[0].getSettings();
this.log.info('startScreenCapture', 'creating capture canvas', {
width: constraints.width,
height: constraints.height,
});
this.captureCanvas = document.createElement('canvas');
this.captureCanvas.width = constraints.width;
this.captureCanvas.height = constraints.height;
this.captureContext = this.captureCanvas.getContext('2d');
}
async stopScreenCapture ( ) {
if (!this.captureStream) {
return;
}
this.capturePreview.pause();
this.capturePreview.srcObject = null;
this.captureStream.getTracks().forEach(track => track.stop());
delete this.captureStream;
if (this.captureContext) {
delete this.captureContext;
}
if (this.captureCanvas) {
delete this.captureCanvas;
}
}
async updateSessionDisplay ( ) {
const NOW = new Date();
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second');
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS');
this.currentSessionBillable.textContent = numeral(this.taskSession.hourlyRate * (duration / 60 / 60)).format('$0,0.00');
}
async captureScreenshot ( ) {
if (!this.captureStream || !this.taskSession) {
return;
}
try {
/*
* Capture the current preview stream frame to the capture canvas
*/
this.captureContext.drawImage(
this.capturePreview,
0, 0,
this.captureCanvas.width,
this.captureCanvas.height,
);
/*
* Generate a PNG Blob from the capture canvas
*/
this.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',
1.0,
);
} catch (error) {
this.log.error('captureScreenshot', 'failed to capture screenshot', { error });
}
}
}