diff --git a/src/features/editor/codemirror/tasklist/index.ts b/src/features/editor/codemirror/tasklist/index.ts index 3edd334..70917f2 100644 --- a/src/features/editor/codemirror/tasklist/index.ts +++ b/src/features/editor/codemirror/tasklist/index.ts @@ -20,7 +20,6 @@ export const createDefaultTaskHandler = ( ): TaskHandler => ({ onTaskClosed: ({ taskContent, originalLine, section, pos, view }: OnTaskClosed) => { const taskIdentifier = generateTaskIdentifier(taskContent); - const timestamp = new Date().toISOString(); // Auto Flush if (taskAutoFlushMode === "instant" && view) { @@ -41,10 +40,12 @@ export const createDefaultTaskHandler = ( const task = Object.freeze({ id: taskIdentifier, content: taskContent, + completed: true, + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), originalLine, taskIdentifier, section, - completedAt: timestamp, }); taskStorage.save(task); @@ -58,3 +59,6 @@ export const createDefaultTaskHandler = ( export const createChecklistPlugin = (taskHandler: TaskHandler): Extension => { return [taskDecoration, taskHoverField, taskMouseInteraction(taskHandler), taskAutoComplete, taskKeyMap]; }; + +export { taskKeyBindings } from "./keymap"; +export { taskAgingPlugin } from "./task-aging"; diff --git a/src/features/editor/codemirror/tasklist/task-aging.test.ts b/src/features/editor/codemirror/tasklist/task-aging.test.ts new file mode 100644 index 0000000..fa3b073 --- /dev/null +++ b/src/features/editor/codemirror/tasklist/task-aging.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { taskAgingPlugin } from "./task-aging"; + +describe("Task Aging", () => { + it("should create editor with task aging plugin without errors", () => { + // Test that the plugin initializes correctly with task content + const state = EditorState.create({ + doc: "- [ ] Test task\n- [ ] Another task", + extensions: [taskAgingPlugin], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe("- [ ] Test task\n- [ ] Another task"); + }); + + it("should create editor with task aging plugin", () => { + const state = EditorState.create({ + doc: "- [ ] New task\n- [x] Completed task", + extensions: [taskAgingPlugin], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe("- [ ] New task\n- [x] Completed task"); + }); + + it("should handle nested tasks", () => { + const state = EditorState.create({ + doc: "- [ ] Parent task\n - [ ] Child task\n - [ ] Another child", + extensions: [taskAgingPlugin], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe("- [ ] Parent task\n - [ ] Child task\n - [ ] Another child"); + }); +}); diff --git a/src/features/editor/codemirror/tasklist/task-aging.ts b/src/features/editor/codemirror/tasklist/task-aging.ts new file mode 100644 index 0000000..f12bd1c --- /dev/null +++ b/src/features/editor/codemirror/tasklist/task-aging.ts @@ -0,0 +1,397 @@ +import { Decoration, type DecorationSet, type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { RangeSetBuilder } from "@codemirror/state"; +import type { Text } from "@codemirror/state"; +import { atomWithStorage } from "jotai/utils"; +import { getDefaultStore } from "jotai"; +import { LOCAL_STORAGE_KEYS } from "../../../../utils/constants"; +import { taskAgingEnabledAtom } from "../../../../utils/hooks/use-task-aging"; + +type ISOString = string & { readonly __brand: "ISOString" }; +// `indent:content:line` +type TaskKey = `${number}:${string}:${number}` & { readonly __brand: "TaskKey" }; + +const createISOString = (): ISOString => { + return new Date().toISOString() as ISOString; +}; + +// Type guards +const isValidISOString = (value: string): value is ISOString => { + try { + const date = new Date(value); + return !isNaN(date.getTime()) && date.toISOString() === value; + } catch { + return false; + } +}; + +// TODO: Handle Error in a better way +const createTaskKey = (taskContent: string, indentLevel: number, lineNumber: number): TaskKey => { + if (indentLevel < 0 || lineNumber <= 0 || taskContent.trim().length === 0) { + throw new Error(`Invalid task key parameters: indent=${indentLevel}, line=${lineNumber}, content="${taskContent}"`); + } + return `${indentLevel}:${taskContent}:${lineNumber}` as TaskKey; +}; + +const isValidTaskKey = (value: string): value is TaskKey => { + const parts = value.split(":"); + if (parts.length !== 3) return false; + + const [indentStr, content, lineStr] = parts; + const indent = parseInt(indentStr, 10); + const line = parseInt(lineStr, 10); + + return !isNaN(indent) && + indent >= 0 && + typeof content === "string" && + content.length > 0 && + !isNaN(line) && + line > 0; +}; + + +// Task creation times storage using atomWithStorage - now using ISO strings for consistency +const taskCreationTimesAtom = atomWithStorage>(LOCAL_STORAGE_KEYS.TASK_AGING_TIMES, {}); + +const store = getDefaultStore(); + +// Get task creation times from atom +const getTaskCreationTimes = (): Record => { + return store.get(taskCreationTimesAtom); +}; + +// Set task creation times to atom +const setTaskCreationTimes = (times: Record): void => { + store.set(taskCreationTimesAtom, times); +}; + +// Get task aging enabled state from atom +const isTaskAgingEnabled = (): boolean => { + return store.get(taskAgingEnabledAtom); +}; + +// Calculate task opacity based on age with proper error handling +const calculateTaskOpacity = (createdAt: ISOString): number => { + try { + const now = Date.now(); + const createdAtTime = new Date(createdAt).getTime(); + + if (isNaN(createdAtTime)) { + console.warn(`Invalid date string for task aging: ${createdAt}`); + return 1.0; // Default to full opacity for invalid dates + } + + // For testing purposes, use very short time intervals 24 + const ageInSeconds = (now - createdAtTime) / 1000; // Test in seconds + + if (ageInSeconds <= 10) return 1.0; // 0-10 seconds: full color + if (ageInSeconds <= 20) return 0.8; // 10-20 seconds: slightly faded + if (ageInSeconds <= 40) return 0.6; // 20-40 seconds: more faded + return 0.4; // 40+ seconds: quite faded + } catch (error) { + console.error(`Error calculating task opacity for ${createdAt}:`, error); + return 1.0; // Default to full opacity on error + } +}; + +// Register task creation time +export const registerTaskCreation = (taskKey: TaskKey, taskContent: string, indentLevel: number): void => { + const taskCreationTimes = getTaskCreationTimes(); + if (!(taskKey in taskCreationTimes)) { + // Try to migrate from existing task with same content first + migrateTaskByContent(taskContent, indentLevel, taskKey); + + // If no migration happened, create new entry + const updatedTimes = getTaskCreationTimes(); + if (!(taskKey in updatedTimes)) { + updatedTimes[taskKey] = createISOString(); + setTaskCreationTimes(updatedTimes); + } + } +}; + +// Reset task creation time (when task is edited) +const resetTaskCreation = (taskKey: TaskKey): void => { + const taskCreationTimes = getTaskCreationTimes(); + taskCreationTimes[taskKey] = createISOString(); + setTaskCreationTimes(taskCreationTimes); +}; + +// Find and migrate task creation time based on content when task moves to different line +const migrateTaskByContent = (taskContent: string, indentLevel: number, newKey: TaskKey): void => { + const taskCreationTimes = getTaskCreationTimes(); + let hasInvalidKeys = false; + let updatedTimes = { ...taskCreationTimes }; + + // Look for existing keys with same content but different line numbers + for (const [existingKey, timestamp] of Object.entries(taskCreationTimes)) { + // Validate existing key format + if (!isValidTaskKey(existingKey)) { + console.warn(`Invalid task key found during migration: ${existingKey}`); + // Create new object without the invalid key + updatedTimes = Object.fromEntries( + Object.entries(updatedTimes).filter(([k]) => k !== existingKey) + ) as Record; + hasInvalidKeys = true; + continue; + } + + const keyParts = existingKey.split(":"); + if (keyParts.length === 3) { + const existingIndent = parseInt(keyParts[0], 10); + const existingContent = keyParts[1]; + + if (!isNaN(existingIndent) && + existingIndent === indentLevel && + existingContent === taskContent && + existingKey !== newKey) { + // Found matching content, migrate if new key doesn't exist + if (!(newKey in updatedTimes) && isValidISOString(timestamp)) { + // Create new object with migrated key + updatedTimes = Object.fromEntries([ + ...Object.entries(updatedTimes).filter(([k]) => k !== existingKey), + [newKey, timestamp] + ]) as Record; + setTaskCreationTimes(updatedTimes); + return; // Only migrate once + } + } + } + } + + // Update if we found invalid keys + if (hasInvalidKeys) { + setTaskCreationTimes(updatedTimes); + } +}; + +// Clean up old entries (older than 7 days) to prevent localStorage bloat +const cleanupOldEntries = (): void => { + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const taskCreationTimes = getTaskCreationTimes(); + + const validEntries = Object.fromEntries( + Object.entries(taskCreationTimes).filter(([key, timestamp]) => { + // Remove entries with invalid key format + if (!isValidTaskKey(key)) { + console.warn(`Invalid task key found during cleanup: ${key}`); + return false; + } + + // Remove entries with invalid timestamp format + if (!isValidISOString(timestamp)) { + console.warn(`Invalid timestamp found during cleanup: ${timestamp}`); + return false; + } + + try { + const timestampDate = new Date(timestamp).getTime(); + if (isNaN(timestampDate)) { + console.warn(`Invalid timestamp date during cleanup: ${timestamp}`); + return false; + } + + // Keep entries that are newer than 7 days + return timestampDate >= sevenDaysAgo; + } catch (error) { + console.warn(`Error parsing timestamp during cleanup: ${timestamp}`, error); + return false; + } + }) + ) as Record; + + // Only update if changes were made + if (Object.keys(validEntries).length !== Object.keys(taskCreationTimes).length) { + setTaskCreationTimes(validEntries); + } +}; + +// Initialize cleanup on module load +cleanupOldEntries(); + +// Task information interface +interface TaskInfo { + readonly content: string; + readonly indent: number; + readonly key: TaskKey; +} + +// Task aging plugin +export const taskAgingPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + updateTimer: number | null = null; + previousTasksMap = new Map(); + + constructor(view: EditorView) { + this.buildTasksMap(view.state.doc); + this.decorations = this.buildDecorations(view); + this.scheduleUpdate(view); + } + + // Build a map of current tasks for comparison + buildTasksMap(doc: Text): void { + this.previousTasksMap.clear(); + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const taskMatch = line.text.match(/^(\s*)-\s*\[([ x])\]\s*(.*)$/); + + if (taskMatch?.[2] === " ") { + const indentLevel = taskMatch[1]?.length ?? 0; + const taskContent = taskMatch[3]?.trim() ?? ""; + const taskKey = createTaskKey(taskContent, indentLevel, i); + + this.previousTasksMap.set(i, { + content: taskContent, + indent: indentLevel, + key: taskKey, + }); + } + } + } + + update(update: ViewUpdate): void { + if (update.docChanged || update.viewportChanged) { + // Handle task migrations and detect actual edits + this.handleTaskUpdates(update.view.state.doc); + + // Clean up old task entries when document changes + this.cleanupOldTasks(update.view); + this.decorations = this.buildDecorations(update.view); + + // Update tasks map for next iteration + this.buildTasksMap(update.view.state.doc); + } + } + + // Handle task updates by migrating keys and detecting actual content changes + handleTaskUpdates(doc: Text): void { + const currentTasksMap = new Map(); + + // Build current tasks map + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const taskMatch = line.text.match(/^(\s*)-\s*\[([ x])\]\s*(.*)$/); + + if (taskMatch?.[2] === " ") { + const indentLevel = taskMatch[1]?.length ?? 0; + const taskContent = taskMatch[3]?.trim() ?? ""; + const taskKey = createTaskKey(taskContent, indentLevel, i); + + currentTasksMap.set(i, { + content: taskContent, + indent: indentLevel, + key: taskKey, + }); + } + } + + // Compare with previous state and handle changes + for (const [lineNum, currentTask] of currentTasksMap) { + const previousTask = this.previousTasksMap.get(lineNum); + + if (previousTask) { + if (previousTask.content !== currentTask.content) { + // Content changed - reset creation time + resetTaskCreation(currentTask.key); + } else if (previousTask.key !== currentTask.key) { + // Position changed but content same - migrate creation time + migrateTaskByContent(currentTask.content, currentTask.indent, currentTask.key); + } + } + // If no previous task at this line, registerTaskCreation will handle it in buildDecorations + } + } + + // Periodically update opacity + scheduleUpdate(view: EditorView): void { + this.updateTimer = window.setTimeout(() => { + if (view.state) { + this.decorations = this.buildDecorations(view); + view.requestMeasure(); + this.scheduleUpdate(view); + } + }, 5000); // Update every 5 seconds (for testing) + } + + destroy(): void { + if (this.updateTimer !== null) { + window.clearTimeout(this.updateTimer); + } + } + + buildDecorations(view: EditorView): DecorationSet { + // Return empty decoration set if task aging is disabled + if (!isTaskAgingEnabled()) { + return Decoration.none; + } + + const builder = new RangeSetBuilder(); + const doc = view.state.doc; + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const taskMatch = line.text.match(/^(\s*)-\s*\[([ x])\]\s*(.*)$/); + + if (taskMatch?.[2] === " ") { + // Only incomplete tasks + const indentLevel = taskMatch[1]?.length ?? 0; + const taskContent = taskMatch[3]?.trim() ?? ""; + const taskKey = createTaskKey(taskContent, indentLevel, i); + + // Register creation time for new tasks + registerTaskCreation(taskKey, taskContent, indentLevel); + + const createdAt = getTaskCreationTimes()[taskKey]; + if (createdAt && isValidISOString(createdAt)) { + const opacity = calculateTaskOpacity(createdAt); + + const decoration = Decoration.line({ + attributes: { + style: `opacity: ${opacity}; transition: opacity 0.3s ease-in-out;`, + class: "cm-task-aging", + }, + }); + builder.add(line.from, line.from, decoration); + } + } + } + + return builder.finish(); + } + + // Clean up entries for tasks that no longer exist + cleanupOldTasks(view: EditorView): void { + const currentTaskKeys = new Set(); + const doc = view.state.doc; + + // Collect keys for currently existing tasks + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const taskMatch = line.text.match(/^(\s*)-\s*\[([ x])\]\s*(.*)$/); + if (taskMatch?.[2] === " ") { + const indentLevel = taskMatch[1]?.length ?? 0; + const taskContent = taskMatch[3]?.trim() ?? ""; + const taskKey = createTaskKey(taskContent, indentLevel, i); + currentTaskKeys.add(taskKey); + } + } + + // Create new object with only existing tasks + const taskCreationTimes = getTaskCreationTimes(); + const filteredTimes = Object.fromEntries( + Object.entries(taskCreationTimes).filter(([key]) => + currentTaskKeys.has(key as TaskKey) + ) + ) as Record; + + // Persist changes if any deletions occurred + if (Object.keys(filteredTimes).length !== Object.keys(taskCreationTimes).length) { + setTaskCreationTimes(filteredTimes); + } + } + }, + { + decorations: (v) => v.decorations, + }, +); diff --git a/src/features/editor/codemirror/use-markdown-editor.ts b/src/features/editor/codemirror/use-markdown-editor.ts index c40a3cf..768f406 100644 --- a/src/features/editor/codemirror/use-markdown-editor.ts +++ b/src/features/editor/codemirror/use-markdown-editor.ts @@ -12,7 +12,7 @@ import { useFontFamily } from "../../../utils/hooks/use-font"; import { DprintMarkdownFormatter } from "../markdown/formatter/dprint-markdown-formatter"; import { getRandomQuote } from "../quotes"; import { taskStorage } from "../tasks/task-storage"; -import { createDefaultTaskHandler, createChecklistPlugin } from "./tasklist"; +import { createDefaultTaskHandler, createChecklistPlugin, taskAgingPlugin } from "./tasklist"; import { registerTaskHandler } from "./tasklist/task-close"; import { snapshotStorage } from "../../snapshots/snapshot-storage"; import { useEditorTheme } from "./use-editor-theme"; @@ -222,6 +222,7 @@ export const useMarkdownEditor = ( preventDefault: true, }, ]), + taskAgingPlugin, urlClickPlugin, urlHoverTooltip, ], diff --git a/src/features/editor/tasks/task-storage.ts b/src/features/editor/tasks/task-storage.ts index 14affd6..b388d46 100644 --- a/src/features/editor/tasks/task-storage.ts +++ b/src/features/editor/tasks/task-storage.ts @@ -9,14 +9,28 @@ import { type StorageProvider, } from "../../../utils/storage"; +export type Task = { + id: string; + content: string; + completed: boolean; + createdAt: string; // ISO string when task was created + completedAt?: string; // ISO string when task was completed + originalLine?: string; + taskIdentifier?: string; + section?: string | undefined; +} + export type TaskStorage = { - getAll: () => CompletedTask[]; - getById: (id: string) => CompletedTask | null; - save: (task: CompletedTask) => void; + getAll: () => Task[]; + getById: (id: string) => Task | null; + save: (task: Task) => void; deleteById: (id: string) => void; deleteByIdentifier: (taskIdentifier: string) => void; deleteAll: () => void; - getByDate: (filter?: DateFilter) => Record; + getByDate: (filter?: DateFilter) => Record; + addTask: (content: string) => Task; + toggleTask: (id: string) => boolean; + getCompletedTasks: () => CompletedTask[]; }; /** * Generate a unique identifier for a task based on its content @@ -33,10 +47,10 @@ export const generateTaskIdentifier = (taskContent: string): string => { // Task Storage factory function const createTaskStorage = (storage: StorageProvider = createBrowserLocalStorage()): TaskStorage => { - const baseStorage = createStorage(storage, LOCAL_STORAGE_KEYS.COMPLETED_TASKS); + const baseStorage = createStorage(storage, LOCAL_STORAGE_KEYS.COMPLETED_TASKS); // Task specific operations - const save = (task: CompletedTask): void => { + const save = (task: Task): void => { baseStorage.save(task); }; @@ -58,10 +72,58 @@ const createTaskStorage = (storage: StorageProvider = createBrowserLocalStorage( baseStorage.deleteAll(); }; - const getByDate = (filter?: DateFilter): Record => { + const getByDate = (filter?: DateFilter): Record => { + const tasks = baseStorage.getAll(); + // Convert tasks to have string timestamps for filtering + const tasksWithStringTimestamps = tasks.map((task) => ({ + ...task, + completedAt: task.completedAt ? new Date(task.completedAt).toISOString() : undefined, + timestamp: task.createdAt ? new Date(task.createdAt).toISOString() : undefined, + })); + const filteredTasks = filterItemsByDate(tasksWithStringTimestamps, filter); + // Convert back to original Task format + return groupItemsByDate(filteredTasks) as Record; + }; + + const addTask = (content: string): Task => { + const task: Task = { + id: crypto.randomUUID(), + content, + completed: false, + createdAt: new Date().toISOString(), + taskIdentifier: generateTaskIdentifier(content), + }; + baseStorage.save(task); + return task; + }; + + const toggleTask = (id: string): boolean => { + const task = baseStorage.getById(id); + if (!task) return false; + + task.completed = !task.completed; + if (task.completed) { + task.completedAt = new Date().toISOString(); + } else { + task.completedAt = undefined; + } + + baseStorage.save(task); + return true; + }; + + const getCompletedTasks = (): CompletedTask[] => { const tasks = baseStorage.getAll(); - const filteredTasks = filterItemsByDate(tasks, filter); - return groupItemsByDate(filteredTasks); + return tasks + .filter((task) => task.completed && task.completedAt) + .map((task) => ({ + id: task.id, + content: task.content, + completedAt: task.completedAt ? new Date(task.completedAt).toISOString() : "", + originalLine: task.originalLine || "", + taskIdentifier: task.taskIdentifier || generateTaskIdentifier(task.content), + section: task.section, + })); }; return { @@ -71,6 +133,9 @@ const createTaskStorage = (storage: StorageProvider = createBrowserLocalStorage( deleteByIdentifier, deleteAll: purgeAll, getByDate, + addTask, + toggleTask, + getCompletedTasks, }; }; diff --git a/src/features/history/use-history-data.ts b/src/features/history/use-history-data.ts index 530f089..dd8eb08 100644 --- a/src/features/history/use-history-data.ts +++ b/src/features/history/use-history-data.ts @@ -34,8 +34,9 @@ const getDateString = (date: Date): string => { const time = date.getTime(); const cacheKey = `date_${time}`; - if (dateStringCache.has(cacheKey)) { - return dateStringCache.get(cacheKey)!; + const cached = dateStringCache.get(cacheKey); + if (cached) { + return cached; } const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD @@ -138,7 +139,7 @@ export const useHistoryData = (): HistoryData => { new Promise((resolve) => { try { const allTasks = taskStorage - .getAll() + .getCompletedTasks() .sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()); setTasks(allTasks); } catch (taskError) { diff --git a/src/features/menu/command-menu.tsx b/src/features/menu/command-menu.tsx index 06fad4d..ad4efb9 100644 --- a/src/features/menu/command-menu.tsx +++ b/src/features/menu/command-menu.tsx @@ -7,6 +7,7 @@ import { showToast } from "../../utils/components/toast"; import { usePaperMode } from "../../utils/hooks/use-paper-mode"; import { COLOR_THEME } from "../../utils/theme-initializer"; import { useEditorWidth } from "../../utils/hooks/use-editor-width"; +import { useTaskAging } from "../../utils/hooks/use-task-aging"; import { useFontFamily, FONT_FAMILIES, FONT_FAMILY_OPTIONS } from "../../utils/hooks/use-font"; import type { EditorView } from "@codemirror/view"; import { fetchGitHubIssuesTaskList } from "../integration/github/github-api"; @@ -25,6 +26,7 @@ import { TextAaIcon, NotebookIcon, GithubLogoIcon, + Clock, } from "@phosphor-icons/react"; import { snapshotStorage } from "../snapshots/snapshot-storage"; @@ -74,6 +76,7 @@ export const CommandMenu = ({ const { paperMode: currentPaperMode, cyclePaperMode } = usePaperMode(); const { editorWidth: currentEditorWidth, toggleEditorWidth } = useEditorWidth(); const { fontFamily, setFontFamily } = useFontFamily(); + const { taskAgingMode, toggleTaskAgingMode } = useTaskAging(); const formatterRef = useMarkdownFormatter(); const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); @@ -118,6 +121,12 @@ export const CommandMenu = ({ onClose(); }; + const toggleTaskAgingCallback = () => { + const updatedTaskAgingMode = toggleTaskAgingMode(); + showToast(`Task aging ${updatedTaskAgingMode ? "enabled" : "disabled"}`, "success"); + onClose(); + }; + const openTaskModal = () => { if (!onOpenHistoryModal) return; onClose(); // Close command menu first @@ -360,6 +369,12 @@ export const CommandMenu = ({ icon: , perform: handleSaveSnapshot, }); + list.push({ + id: "task-aging", + name: `${taskAgingMode ? "Disable" : "Enable"} task aging`, + icon: , + perform: toggleTaskAgingCallback, + }); list.push({ id: "github-repo", name: "Go to Ephe GitHub Repo", @@ -425,7 +440,7 @@ export const CommandMenu = ({ className="mb-1 px-1 font-medium text-neutral-500 text-xs tracking-wider dark:text-neutral-400" > {commandsList() - .filter((cmd) => ["theme-toggle", "paper-mode", "editor-width", "font-family"].includes(cmd.id)) + .filter((cmd) => ["theme-toggle", "paper-mode", "editor-width", "font-family", "task-aging"].includes(cmd.id)) .map((command) => ( )} + {command.id === "task-aging" && ( + + ({taskAgingMode ? "on" : "off"}) + + )} {command.shortcut && ( diff --git a/src/features/menu/system-menu.tsx b/src/features/menu/system-menu.tsx index 2309be5..4ce41f5 100644 --- a/src/features/menu/system-menu.tsx +++ b/src/features/menu/system-menu.tsx @@ -4,6 +4,7 @@ import { useTheme } from "../../utils/hooks/use-theme"; import { usePaperMode } from "../../utils/hooks/use-paper-mode"; import { useEditorWidth } from "../../utils/hooks/use-editor-width"; import { useCharCount } from "../../utils/hooks/use-char-count"; +import { useTaskAging } from "../../utils/hooks/use-task-aging"; import { useWordCount } from "../../utils/hooks/use-word-count"; import { useFontFamily, FONT_FAMILY_OPTIONS, FONT_FAMILIES } from "../../utils/hooks/use-font"; import { useEditorMode } from "../../utils/hooks/use-editor-mode"; @@ -21,6 +22,7 @@ import { TextAaIcon, NotebookIcon, FloppyDiskIcon, + ClockIcon, SquaresFourIcon, } from "@phosphor-icons/react"; import { taskStorage } from "../editor/tasks/task-storage"; @@ -85,6 +87,7 @@ export const SystemMenu = ({ onOpenHistoryModal }: SystemMenuProps) => { const { todayCompletedTasks } = useTodayCompletedTasks(menuOpen); const { snapshotCount } = useSnapshotCount(menuOpen); const { taskAutoFlushMode, setTaskAutoFlushMode } = useTaskAutoFlush(); + const { taskAgingMode, toggleTaskAgingMode } = useTaskAging(); const openTaskSnapshotModal = (tabIndex: number) => { if (!onOpenHistoryModal) return; @@ -261,7 +264,6 @@ export const SystemMenu = ({ onOpenHistoryModal }: SystemMenuProps) => { -
Task
@@ -278,6 +280,21 @@ export const SystemMenu = ({ onOpenHistoryModal }: SystemMenuProps) => { Task Flush: {taskAutoFlushMode} + + + +
)} diff --git a/src/features/time-display/hours-display.tsx b/src/features/time-display/hours-display.tsx index ad2c24a..68fb19d 100644 --- a/src/features/time-display/hours-display.tsx +++ b/src/features/time-display/hours-display.tsx @@ -77,6 +77,7 @@ export const HoursDisplay = () => { backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)", }} + role="tooltip" onMouseLeave={() => setShowTooltip(false)} >
diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 071abf1..f0e7d17 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -8,6 +8,8 @@ export const LOCAL_STORAGE_KEYS = { PREVIEW_MODE: "ephe:preview-mode", TOC_MODE: "ephe:toc-mode", TASK_AUTO_FLUSH_MODE: "ephe:task-auto-flush-mode", + TASK_AGING_ENABLED: "ephe:task-aging-enabled", + TASK_AGING_TIMES: "ephe:task-aging-times", FONT_FAMILY: "ephe:font-family", CURSOR_POSITION: "ephe:cursor-position", DOCUMENTS: "ephe:documents", diff --git a/src/utils/hooks/use-task-aging.ts b/src/utils/hooks/use-task-aging.ts new file mode 100644 index 0000000..2661b2b --- /dev/null +++ b/src/utils/hooks/use-task-aging.ts @@ -0,0 +1,21 @@ +import { atomWithStorage } from "jotai/utils"; +import { useAtom } from "jotai"; +import { LOCAL_STORAGE_KEYS } from "../constants"; + +export const taskAgingEnabledAtom = atomWithStorage(LOCAL_STORAGE_KEYS.TASK_AGING_ENABLED, true); + +export const useTaskAging = () => { + const [taskAgingMode, setTaskAgingMode] = useAtom(taskAgingEnabledAtom); + + const toggleTaskAgingMode = (): boolean => { + const newState = !taskAgingMode; + setTaskAgingMode(newState); + return newState; + }; + + return { + taskAgingMode, + toggleTaskAgingMode, + setTaskAgingMode, + }; +};