aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/TaskBox.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/TaskBox.tsx')
-rw-r--r--src/client/views/nodes/TaskBox.tsx670
1 files changed, 670 insertions, 0 deletions
diff --git a/src/client/views/nodes/TaskBox.tsx b/src/client/views/nodes/TaskBox.tsx
new file mode 100644
index 000000000..ed5982c55
--- /dev/null
+++ b/src/client/views/nodes/TaskBox.tsx
@@ -0,0 +1,670 @@
+import { action, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { DateField } from '../../../fields/DateField';
+import { BoolCast, DateCast, DocCast, NumCast, StrCast } from '../../../fields/Types';
+import { GoogleAuthenticationManager } from '../../apis/GoogleAuthenticationManager';
+import { Docs } from '../../documents/Documents';
+import { DocumentType } from '../../documents/DocumentTypes';
+import { ViewBoxBaseComponent } from '../DocComponent';
+import { FieldView, FieldViewProps } from './FieldView';
+import './TaskBox.scss';
+import { DocumentDecorations } from '../DocumentDecorations';
+import { Doc } from '../../../fields/Doc';
+import { DocumentView } from './DocumentView';
+
+/**
+ * TaskBox class for adding task information + completing tasks
+ */
+@observer
+export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() {
+ _googleTaskCreateDisposer?: IReactionDisposer;
+ _heightDisposer?: IReactionDisposer;
+ _widthDisposer?: IReactionDisposer;
+ @observable _needsSync = false; // Whether the task needs to be synced with Google Tasks
+ @observable _syncing = false; // Whether the task is currently syncing with Google Tasks
+ private _isFocused = false; // Whether the task box is currently focused
+
+ // contains the last synced task information
+ private _lastSyncedTask: {
+ title: string;
+ text: string;
+ due?: string;
+ completed: boolean;
+ deleted?: boolean;
+ } = {
+ title: '',
+ text: '',
+ due: '',
+ completed: false,
+ deleted: false,
+ };
+
+ /**
+ * Getter for needsSync
+ */
+ get needsSync() {
+ return this._needsSync;
+ }
+
+ /**
+ * Constructor for the task box
+ * @param props - props containing the document reference
+ */
+ constructor(props: FieldViewProps) {
+ super(props);
+ makeObservable(this);
+ }
+
+ /**
+ * Return the JSX string that will create this component
+ * @param fieldStr the Doc field that contains the primary data for this component
+ * @returns
+ */
+ public static LayoutString(fieldStr: string) {
+ return FieldView.LayoutString(TaskBox, fieldStr);
+ }
+
+ /**
+ * Method to update the task description
+ * @param e - event of changing the description box input
+ */
+ @action
+ updateText = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
+ this.Document[this.fieldKey] = e.target.value;
+ };
+
+ /**
+ * Method to update the task title
+ * @param e - event of changing the title box input
+ */
+ @action
+ updateTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
+ this.Document.title = e.target.value;
+ };
+
+ /**
+ * Method to update the all day status
+ * @param e - event of changing the all day checkbox
+ */
+ @action
+ updateAllDay = (e: React.ChangeEvent<HTMLInputElement>) => {
+ this.Document.$task_allDay = e.target.checked;
+
+ if (e.target.checked) {
+ delete this.Document.$task_startTime;
+ delete this.Document.$task_endTime;
+ }
+
+ this.setTaskDateRange();
+ };
+
+ /**
+ * Method to update the task start time
+ * @param e - event of changing the start time input
+ */
+ @action
+ updateStart = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const newStart = new Date(e.target.value);
+
+ this.Document.$task_startTime = new DateField(newStart);
+
+ const endDate = this.Document.$task_endTime instanceof DateField ? this.Document.$task_endTime.date : undefined;
+ if (endDate && newStart > endDate) {
+ // Alert user
+ alert('Start time cannot be after end time. End time has been adjusted.');
+
+ // Fix end time
+ const adjustedEnd = new Date(newStart.getTime() + 60 * 60 * 1000);
+ this.Document.$task_endTime = new DateField(adjustedEnd);
+ }
+
+ this.setTaskDateRange();
+ };
+
+ /**
+ * Method to update the task end time
+ * @param e - event of changing the end time input
+ */
+ @action
+ updateEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const newEnd = new Date(e.target.value);
+
+ this.Document.$task_endTime = new DateField(newEnd);
+
+ const startDate = this.Document.$task_startTime instanceof DateField ? this.Document.$task_startTime.date : undefined;
+ if (startDate && newEnd < startDate) {
+ // Alert user
+ alert('End time cannot be before start time. Start time has been adjusted.');
+
+ // Fix start time
+ const adjustedStart = new Date(newEnd.getTime() - 60 * 60 * 1000);
+ this.Document.$task_startTime = new DateField(adjustedStart);
+ }
+
+ this.setTaskDateRange();
+ };
+
+ /**
+ * Method to update the task date range
+ */
+ @action
+ setTaskDateRange() {
+ const doc = this.Document;
+
+ if (doc.$task_allDay) {
+ const range = StrCast(doc.$task_dateRange).split('|');
+ const dateStr = range[0] ?? new Date().toISOString().split('T')[0]; // default to today
+
+ doc.$task_dateRange = `${dateStr}|${dateStr}`;
+ doc.$task_allDay = true;
+ } else {
+ const startField = doc.$task_startTime;
+ const endField = doc.$task_endTime;
+ const startDate = startField instanceof DateField ? startField.date : null;
+ const endDate = endField instanceof DateField ? endField.date : null;
+
+ if (startDate && endDate && !isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
+ doc.$task_dateRange = `${startDate.toISOString()}|${endDate.toISOString()}`;
+ doc.$task_allDay = false;
+ }
+ }
+ }
+
+ /**
+ * Method to set task's completion status
+ * @param e - event of changing the "completed" input checkbox
+ */
+
+ @action
+ toggleComplete = (e: React.ChangeEvent<HTMLInputElement>) => {
+ this.Document.$task_completed = e.target.checked;
+ };
+
+ /**
+ * Computes due date for the task (for Google Tasks API)
+ * @returns - a string representing the due date in ISO format, or undefined if no valid date is found
+ */
+ private computeDueDate(): string | undefined {
+ const doc = this.Document;
+ let due: string | undefined;
+ const allDay = !!doc.$task_allDay;
+
+ if (allDay) {
+ const rawRange = StrCast(doc.$task_dateRange);
+ const datePart = rawRange.split('|')[0];
+
+ if (datePart && !isNaN(new Date(datePart).getTime())) {
+ // Set time to midnight UTC to represent the start of the all-day event
+ const baseDate = datePart.includes('T') ? datePart : datePart + 'T00:00:00Z';
+ due = new Date(baseDate).toISOString();
+ } else {
+ due = undefined;
+ }
+ } else if (doc.$task_endTime instanceof DateField && doc.$task_endTime.date) {
+ due = doc.$task_endTime.date.toISOString();
+ } else if (doc.$task_startTime instanceof DateField && doc.$task_startTime.date) {
+ due = doc.$task_startTime.date.toISOString();
+ } else {
+ due = undefined;
+ }
+
+ return due;
+ }
+
+ /**
+ * Builds the body for the Google Tasks API request
+ * @returns - an object containing the task details
+ */
+
+ private buildGoogleTaskBody(): Record<string, string | boolean | undefined> {
+ const doc = this.Document;
+ const title = StrCast(doc.title, 'Untitled Task');
+ const notes = StrCast(doc[this.fieldKey]);
+ const due = this.computeDueDate();
+ const completed = !!doc.$task_completed;
+
+ const body: Record<string, string | boolean | undefined> = {
+ title,
+ notes,
+ due,
+ status: completed ? 'completed' : 'needsAction',
+ completed: completed ? new Date().toISOString() : undefined,
+ };
+
+ if (doc.$dashDeleted === true) {
+ body.deleted = true;
+ } else if (doc.$dashDeleted === false) {
+ body.deleted = false;
+ }
+
+ return body;
+ }
+
+ /**
+ * Handles the focus event for the task box (for auto-syncing)
+ */
+
+ handleFocus = () => {
+ if (!this._isFocused) {
+ this._isFocused = true;
+ this.syncWithGoogleTaskBidirectional(true); // silent sync
+ }
+ };
+
+ /**
+ * Handles the blur event for the task box (for auto-syncing)
+ * @param e - the focus event
+ */
+ handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
+ // Check if focus is moving outside this component
+ if (!e.currentTarget.contains(e.relatedTarget)) {
+ this._isFocused = false;
+ this.syncWithGoogleTaskBidirectional(true);
+ }
+ };
+
+ /**
+ * Method to sync the task with Google Tasks bidirectionally
+ * (update Dash from Google and vice versa, based on which is newer)
+ * @param silent - whether to suppress UI prompts to connect to Google (default: false)
+ * @returns - a promise that resolves to true if sync was successful, false otherwise
+ */
+
+ syncWithGoogleTaskBidirectional = async (silent = false): Promise<boolean> => {
+ const doc = this.Document;
+ let token: string | undefined;
+ try {
+ token = silent ? await GoogleAuthenticationManager.Instance.fetchAccessTokenSilently() : await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken();
+ } catch (err) {
+ console.warn('Google auth failed:', err);
+ return false;
+ }
+
+ if (!token) {
+ if (!silent) {
+ const listener = () => {
+ window.removeEventListener('focusin', listener);
+ if (confirm('✅ Authorization complete. Try syncing the task again?')) {
+ // try syncing again
+ this.syncWithGoogleTaskBidirectional();
+ }
+ window.removeEventListener('focusin', listener);
+ };
+ setTimeout(() => window.addEventListener('focusin', listener), 100);
+ }
+ return false;
+ }
+
+ if (!doc.$googleTaskId) return false;
+
+ runInAction(() => {
+ this._syncing = true;
+ });
+
+ try {
+ // Fetch current version of Google Task
+ const response = await fetch(`/googleTasks/${doc.$googleTaskId}`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: 'include',
+ });
+
+ const googleTask = await response.json();
+ const googleUpdated = new Date(googleTask.updated);
+ const dashUpdated = new Date(StrCast(doc.$task_lastSyncedAt));
+
+ const dashChanged =
+ StrCast(doc.title) !== this._lastSyncedTask.title ||
+ StrCast(doc[this.fieldKey]) !== this._lastSyncedTask.text ||
+ this.computeDueDate() !== this._lastSyncedTask.due ||
+ !!doc.$task_completed !== this._lastSyncedTask.completed ||
+ !!doc.$dashDeleted !== this._lastSyncedTask.deleted;
+
+ if (googleUpdated > dashUpdated && !dashChanged) {
+ // Google version is newer — update Dash
+ runInAction(() => {
+ doc.title = googleTask.title ?? doc.title;
+ doc[this.fieldKey] = googleTask.notes ?? doc[this.fieldKey];
+ doc.$task_completed = googleTask.status === 'completed';
+
+ if (googleTask.due && googleTask.due.split('T')[0] !== this.computeDueDate()?.split('T')[0]) {
+ const dueDate = new Date(googleTask.due);
+ doc.$task_allDay = true;
+ doc.$task_dateRange = `${dueDate.toISOString().split('T')[0]}|${dueDate.toISOString().split('T')[0]}`;
+ }
+
+ doc.$task_lastSyncedAt = googleTask.updated;
+ this._lastSyncedTask = {
+ title: StrCast(doc.title),
+ text: StrCast(doc[this.fieldKey]),
+ due: this.computeDueDate(),
+ completed: !!doc.$task_completed,
+ deleted: !!doc.$dashDeleted,
+ };
+ this._needsSync = false;
+ });
+
+ console.log('Pulled newer version from Google');
+ return true;
+ } else if (googleUpdated <= dashUpdated && !dashChanged) {
+ console.log('No changes to sync');
+ return true;
+ } else {
+ // Dash version is newer — push update to Google
+ const body = this.buildGoogleTaskBody();
+ const res = await fetch(`/googleTasks/${doc.$googleTaskId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+
+ const result = await res.json();
+ if (result?.id) {
+ doc.$task_lastSyncedAt = new Date().toISOString();
+ this._lastSyncedTask = {
+ title: StrCast(doc.title),
+ text: StrCast(doc[this.fieldKey]),
+ due: this.computeDueDate(),
+ completed: !!doc.$task_completed,
+ deleted: !!doc.$dashDeleted,
+ };
+ this._needsSync = false;
+ console.log('Pushed newer version to Google');
+ return true;
+ } else {
+ console.warn('❌ Push failed:', result);
+ return false;
+ }
+ }
+ } catch (err) {
+ console.error('❌ Sync error:', err);
+ return false;
+ } finally {
+ runInAction(() => {
+ this._syncing = false;
+ });
+ }
+ };
+
+ /**
+ * Method to set up the task box on mount
+ */
+ componentDidMount() {
+ this.setTaskDateRange();
+ const doc = this.Document;
+
+ // adding task on creation to google
+ (async () => {
+ if (!doc.$googleTaskId && doc.title) {
+ try {
+ const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken();
+ if (!token) return;
+ const body = this.buildGoogleTaskBody();
+
+ const res = await fetch('/googleTasks/create', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const result = await res.json();
+ if (result?.id) {
+ doc.$googleTaskId = result.id;
+ console.log('✅ Google Task created on mount:', result);
+ } else {
+ console.warn('❌ Google Task creation failed:', result);
+ }
+ } catch (err) {
+ console.warn('❌ Error creating Google Task:', err);
+ }
+ } else if (doc.$googleTaskId) {
+ await this.syncWithGoogleTaskBidirectional();
+ }
+ })();
+
+ this._heightDisposer = reaction(
+ () => NumCast(doc._height),
+ height => {
+ const minHeight = NumCast(doc.height_min);
+ if (height < minHeight) {
+ doc._height = minHeight;
+ }
+ }
+ );
+
+ this._widthDisposer = reaction(
+ () => NumCast(doc._width),
+ width => {
+ const minWidth = NumCast(doc.width_min);
+ if (width < minWidth) {
+ doc._width = minWidth;
+ }
+ }
+ );
+
+ runInAction(() => {
+ const completed = BoolCast(doc.$task_completed);
+ const due = this.computeDueDate();
+
+ this._lastSyncedTask = {
+ title: StrCast(doc.title),
+ text: StrCast(doc[this.fieldKey]),
+ due,
+ completed,
+ deleted: !!doc.$dashDeleted,
+ };
+ this._needsSync = false;
+ });
+
+ if (this.Document.$dashDeleted) {
+ runInAction(() => {
+ this.Document.$dashDeleted = false;
+ });
+ }
+
+ this._googleTaskCreateDisposer = reaction(
+ () => {
+ const completed = BoolCast(doc.$task_completed);
+ const due = this.computeDueDate();
+ const dashDeleted = !!doc.$dashDeleted;
+
+ return { title: StrCast(doc.title), text: StrCast(doc[this.fieldKey]), completed, due, dashDeleted };
+ },
+ ({ title, text, completed, due, dashDeleted }) => {
+ this._needsSync = title !== this._lastSyncedTask.title || text !== this._lastSyncedTask.text || due !== this._lastSyncedTask.due || completed !== this._lastSyncedTask.completed || dashDeleted !== this._lastSyncedTask.deleted;
+ },
+ { fireImmediately: true }
+ );
+ }
+
+ /**
+ * Method to clean up the task box on unmount
+ */
+ componentWillUnmount() {
+ const doc = this.Document;
+ this._googleTaskCreateDisposer?.();
+ this._heightDisposer?.();
+ this._widthDisposer?.();
+ }
+
+ /**
+ * Method to handle task deletion
+ * @returns - a promise that resolves when the task is deleted
+ */
+ handleDeleteTask = async () => {
+ const doc = this.Document;
+ if (!doc.$googleTaskId) return;
+ if (!window.confirm('Are you sure you want to delete this task?')) return;
+
+ doc.$dashDeleted = true;
+ this._needsSync = true;
+
+ try {
+ const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken();
+ if (!token) return;
+
+ await fetch(`/googleTasks/${doc.$googleTaskId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ const view = DocumentView.getDocumentView(this.Document);
+ if (view) {
+ DocumentView.SelectView(view, false); // select document
+ DocumentDecorations.Instance?.onCloseClick?.(true); // simulate clicking the close button
+ }
+
+ // Remove the task from the recently closed list
+ Doc.MyRecentlyClosed && Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, this.Document);
+ console.log(`✅ Deleted Google Task ${doc.$googleTaskId}`);
+ } catch (err) {
+ console.warn('❌ Failed to delete Google Task:', err);
+ }
+ };
+
+ /**
+ * Method to render the task box
+ * @returns - HTML with taskbox components
+ */
+
+ render() {
+ function toLocalDateTimeString(date: Date): string {
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
+ }
+
+ const doc = this.Document;
+
+ const taskDesc = StrCast(doc[this.fieldKey]);
+ const taskTitle = StrCast(doc.title);
+ const allDay = !!doc.$task_allDay;
+ const due = this.computeDueDate();
+ const isCompleted = !!this.Document.$task_completed;
+
+ const startTime = DateCast(doc.$task_startTime) ? toLocalDateTimeString(DateCast(doc.$task_startTime)!.date) : '';
+ const endTime = DateCast(doc.$task_endTime) ? toLocalDateTimeString(DateCast(doc.$task_endTime)!.date) : '';
+
+ const handleGoogleTaskSync = async () => {
+ const success = await this.syncWithGoogleTaskBidirectional();
+
+ if (success) {
+ alert('✅ Task successfully synced!');
+ } else {
+ alert('❌ Task sync failed. Try reloading.');
+ }
+ };
+
+ return (
+ <div className="task-box-blur-wrapper" tabIndex={0} onBlur={this.handleBlur} onFocus={this.handleFocus}>
+ <div className="task-manager-container">
+ <input className="task-manager-title" type="text" placeholder="Task Title" value={taskTitle} onChange={this.updateTitle} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} />
+
+ <textarea className="task-manager-description" placeholder="What’s your task?" value={taskDesc} onChange={this.updateText} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} />
+
+ <div className="task-manager-checkboxes">
+ <label className="task-manager-allday" style={{ opacity: isCompleted ? 0.7 : 1 }}>
+ <input type="checkbox" checked={allDay} onChange={this.updateAllDay} disabled={isCompleted} />
+ All day
+ {allDay && (
+ <input
+ type="date"
+ value={(() => {
+ const datePart = StrCast(doc.$task_dateRange).split('|')[0];
+ if (!datePart) return '';
+ const d = new Date(datePart);
+ return !isNaN(d.getTime()) ? d.toISOString().split('T')[0] : '';
+ })()}
+ onChange={e => {
+ const newDate = new Date(e.target.value);
+ if (!isNaN(newDate.getTime())) {
+ const dateStr = e.target.value;
+ if (dateStr) {
+ doc.$task_dateRange = `${dateStr}T00:00:00|${dateStr}T00:00:00`;
+ }
+ }
+ }}
+ disabled={isCompleted}
+ style={{ marginLeft: '8px' }}
+ />
+ )}
+ </label>
+
+ <label className="task-manager-complete">
+ <input type="checkbox" checked={isCompleted} onChange={this.toggleComplete} />
+ Complete
+ </label>
+ </div>
+
+ <div className="task-manager-button-row">
+ <button
+ className="task-manager-google"
+ onClick={event => {
+ event.preventDefault();
+ handleGoogleTaskSync();
+ }}>
+ {this._syncing ? 'Syncing...' : this.needsSync ? 'Push Updates' : 'Sync Task'}
+ </button>
+
+ <button
+ className="task-manager-delete"
+ onClick={event => {
+ event.preventDefault();
+ this.handleDeleteTask();
+ }}>
+ <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="12" height="12" viewBox="0 0 24 24" style={{ fill: 'white', marginRight: '6px', verticalAlign: 'middle', marginTop: '-2px' }}>
+ <path d="M 10 2 L 9 3 L 5 3 C 4.4 3 4 3.4 4 4 C 4 4.6 4.4 5 5 5 L 7 5 L 17 5 L 19 5 C 19.6 5 20 4.6 20 4 C 20 3.4 19.6 3 19 3 L 15 3 L 14 2 L 10 2 z M 5 7 L 5 20 C 5 21.1 5.9 22 7 22 L 17 22 C 18.1 22 19 21.1 19 20 L 19 7 L 5 7 z M 9 9 C 9.6 9 10 9.4 10 10 L 10 19 C 10 19.6 9.6 20 9 20 C 8.4 20 8 19.6 8 19 L 8 10 C 8 9.4 8.4 9 9 9 z M 15 9 C 15.6 9 16 9.4 16 10 L 16 19 C 16 19.6 15.6 20 15 20 C 14.4 20 14 19.6 14 19 L 14 10 C 14 9.4 14.4 9 15 9 z"></path>
+ </svg>
+ Delete
+ </button>
+ </div>
+
+ {!allDay && (
+ <div className="task-manager-times" style={{ opacity: isCompleted ? 0.7 : 1 }}>
+ <label>
+ Start:
+ <input type="datetime-local" value={startTime} onChange={this.updateStart} disabled={isCompleted} />
+ </label>
+ <label>
+ End:
+ <input type="datetime-local" value={endTime} onChange={this.updateEnd} disabled={isCompleted} />
+ </label>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+}
+
+Docs.Prototypes.TemplateMap.set(DocumentType.TASK, {
+ layout: { view: TaskBox, dataField: 'text' },
+ options: {
+ acl: '',
+ _height: 35,
+ _xMargin: 10,
+ _yMargin: 10,
+ _layout_autoHeight: true,
+ _layout_nativeDimEditable: true,
+ _layout_reflowVertical: true,
+ _layout_reflowHorizontal: true,
+ task: '',
+ defaultDoubleClick: 'ignore',
+ systemIcon: 'BsCheckSquare',
+ height_min: 300,
+ width_min: 300,
+ },
+});