diff options
Diffstat (limited to 'src/client/views/nodes/TaskBox.tsx')
-rw-r--r-- | src/client/views/nodes/TaskBox.tsx | 670 |
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, + }, +}); |