diff options
Diffstat (limited to 'src/client/views/nodes/TaskBox.tsx')
-rw-r--r-- | src/client/views/nodes/TaskBox.tsx | 339 |
1 files changed, 194 insertions, 145 deletions
diff --git a/src/client/views/nodes/TaskBox.tsx b/src/client/views/nodes/TaskBox.tsx index 37f6124a1..6c966a59d 100644 --- a/src/client/views/nodes/TaskBox.tsx +++ b/src/client/views/nodes/TaskBox.tsx @@ -15,14 +15,10 @@ import './TaskBox.scss'; */ @observer export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { - /** - * 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); - } + _googleTaskCreateDisposer?: IReactionDisposer; + _heightDisposer?: IReactionDisposer; + _widthDisposer?: IReactionDisposer; + @observable _needsSync = false; // Whether the task needs to be synced with Google Tasks // contains the last synced task information private _lastSyncedTask: { @@ -37,10 +33,9 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { completed: false, }; - // Whether the task needs to be synced with Google Tasks - @observable _needsSync = false; - - // Getter for needsSync + /** + * Getter for needsSync + */ get needsSync() { return this._needsSync; } @@ -55,10 +50,18 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { } /** + * 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; @@ -171,38 +174,127 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { this.Document.$task_completed = e.target.checked; }; - _googleTaskCreateDisposer?: IReactionDisposer; - _heightDisposer?: IReactionDisposer; - _widthDisposer?: IReactionDisposer; + /** + * 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 | 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; + + return { + title, + notes, + due, + status: completed ? 'completed' : 'needsAction', + completed: completed ? new Date().toISOString() : undefined, + }; + } + + /** + * Method to automatically sync the task to Google Tasks when the component loses focus + * @returns - a Promise that resolves when the sync is complete + */ + autoSyncToGoogle = async () => { + if (!this._needsSync) return; + + const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + if (!token) return; + + const doc = this.Document; + const taskTitle = StrCast(doc.title); + const taskDesc = StrCast(doc[this.fieldKey]); + const due = this.computeDueDate(); + const isCompleted = !!doc.$task_completed; + + const body = this.buildGoogleTaskBody(); + + const isUpdate = !!doc.$googleTaskId; + const endpoint = isUpdate ? `/googleTasks/${doc.$googleTaskId}` : '/googleTasks/create'; + const method = isUpdate ? 'PATCH' : 'POST'; + + const res = await fetch(endpoint, { + method, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const result = await res.json(); + if (result?.id) { + runInAction(() => { + this._lastSyncedTask = { title: taskTitle, text: taskDesc, due, completed: isCompleted }; + this._needsSync = false; + }); + console.log('✅ Auto-synced with Google Tasks on blur'); + } else { + console.warn('❌ Auto-sync failed:', result); + } + }; + + /** + * Handles the blur event for the task box + * @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.autoSyncToGoogle(); + } + }; + + /** + * 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: { title: string; notes: string; status: string; completed?: string; due?: string } = { - title: StrCast(doc.title, 'Untitled Task'), - notes: StrCast(doc[this.fieldKey]), - status: doc.$task_completed ? 'completed' : 'needsAction', - completed: doc.$task_completed ? new Date().toISOString() : undefined, - }; - - const datePart = StrCast(doc.$task_dateRange).split('|')[0]; - if (doc.$task_allDay && datePart) { - if (datePart && !isNaN(new Date(datePart).getTime())) { - const baseDate = datePart.includes('T') ? datePart : datePart + 'T00:00:00Z'; - body.due = new Date(baseDate).toISOString(); - } - } else if (doc.$task_endTime instanceof DateField) { - body.due = doc.$task_endTime.date.toISOString(); - } + const body = this.buildGoogleTaskBody(); const res = await fetch('/googleTasks/create', { method: 'POST', @@ -249,22 +341,8 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { runInAction(() => { const completed = BoolCast(doc.$task_completed); - const $task_allDay = BoolCast(doc.$task_allDay); - const endTime = DateCast(doc.$task_endTime); - const startTime = DateCast(doc.$task_startTime); - const datePart = StrCast(doc.$task_dateRange)?.split('|')[0]; - - const due = (() => { - if ($task_allDay && datePart && !isNaN(new Date(datePart).getTime())) { - return new Date(datePart).toISOString(); - } else if (endTime && !isNaN(+endTime.date)) { - return endTime.date.toISOString(); - } else if (startTime && !isNaN(+startTime.date)) { - return startTime.date.toISOString(); - } - return undefined; - })(); - + const due = this.computeDueDate(); + this._lastSyncedTask = { title: StrCast(doc.title), text: StrCast(doc[this.fieldKey]), @@ -277,17 +355,7 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { this._googleTaskCreateDisposer = reaction( () => { const completed = BoolCast(doc.$task_completed); - const $task_allDay = BoolCast(doc.$task_allDay); - const endTime = DateCast(doc.$task_endTime); - const startTime = DateCast(doc.$task_startTime); - const datePart = StrCast(doc.$task_dateRange)?.split('|')[0]; - - const due = (() => { - if ($task_allDay && datePart) { - if (!isNaN(new Date(datePart).getTime())) return new Date(datePart).toISOString(); - } else if (endTime && !isNaN(+endTime.date)) return endTime.date.toISOString(); - else if (startTime && !isNaN(+startTime.date)) return startTime.date.toISOString(); - })(); + const due = this.computeDueDate(); return { title: StrCast(doc.title), text: StrCast(doc[this.fieldKey]), completed, due }; }, @@ -302,6 +370,9 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { ); } + /** + * Method to clean up the task box on unmount + */ componentWillUnmount() { const doc = this.Document; this._googleTaskCreateDisposer?.(); @@ -347,6 +418,7 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { 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) : ''; @@ -370,26 +442,7 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { return; } - let due: string | undefined; - - 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; - } + const body = this.buildGoogleTaskBody(); const isUpdate = !!doc.$googleTaskId; const endpoint = isUpdate ? `/googleTasks/${doc.$googleTaskId}` : '/googleTasks/create'; @@ -402,13 +455,7 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - title: taskTitle || 'Untitled Task', - notes: taskDesc, - due, - status: doc.$task_completed ? 'completed' : 'needsAction', - completed: doc.$task_completed ? new Date().toISOString() : undefined, - }), + body: JSON.stringify(body), }); const result = await response.json(); @@ -435,67 +482,69 @@ export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { }; return ( - <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`; + <div className="task-box-blur-wrapper" tabIndex={0} onBlur={this.handleBlur}> + <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> - - <button - className="task-manager-google" - disabled={!this.needsSync} - onClick={event => { - event.preventDefault(); - handleGoogleTaskSync(); - }}> - Sync to Google - </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} /> + }} + disabled={isCompleted} + style={{ marginLeft: '8px' }} + /> + )} </label> - <label> - End: - <input type="datetime-local" value={endTime} onChange={this.updateEnd} disabled={isCompleted} /> + + <label className="task-manager-complete"> + <input type="checkbox" checked={isCompleted} onChange={this.toggleComplete} /> + Complete </label> + + <button + className="task-manager-google" + disabled={!this.needsSync} + onClick={event => { + event.preventDefault(); + handleGoogleTaskSync(); + }}> + Sync to Google + </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> ); } |