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() { _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) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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 { 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 = { 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) => { // 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 => { 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 (