todoist-integration-addendum.md
1 # Todoist Integration Addendum 2 3 ## Nested Workspaces Browser Extension - Phase 10 4 5 This addendum extends the master architecture with Todoist task management integration, enabling workspace-specific project synchronization and task CRUD operations within the dashboard. 6 7 --- 8 9 ## Overview 10 11 The Todoist integration creates a bidirectional sync between browser workspaces and Todoist projects, organized under a master "Mnemonic" project. Users can view, create, edit, complete, and schedule tasks directly from the dashboard without leaving their workflow. 12 13 ### Feature Summary 14 15 | Capability | Description | 16 |------------|-------------| 17 | **Project Mapping** | Each workspace maps to a Todoist project under "Mnemonic" master | 18 | **Hierarchical Structure** | Parent/child workspace relationships preserved in Todoist | 19 | **Task CRUD** | Full create, read, update, delete operations | 20 | **Due Dates** | Natural language scheduling ("tomorrow", "next Monday") | 21 | **Priority Levels** | P1-P4 priority support with visual indicators | 22 | **Completion Tracking** | Toggle completion with sync back to Todoist | 23 | **Labels** | Workspace-aware labels for cross-cutting concerns | 24 25 --- 26 27 ## Official SDK 28 29 ### Package Information 30 31 The current official Todoist client for JavaScript/TypeScript is the `@doist/todoist-api-typescript` package, which targets the REST API v2 and is actively maintained. 32 33 | Property | Value | 34 |----------|-------| 35 | **Package** | `@doist/todoist-api-typescript` | 36 | **Version** | 6.2.1 (as of December 2024) | 37 | **License** | MIT | 38 | **API Version** | REST API v2 / Todoist API v1 | 39 | **TypeScript** | Full type definitions included | 40 41 ### Installation 42 43 ```bash 44 npm install @doist/todoist-api-typescript 45 ``` 46 47 ### Documentation Resources 48 49 | Resource | URL | 50 |----------|-----| 51 | GitHub Repository | https://github.com/Doist/todoist-api-typescript | 52 | API Documentation | https://doist.github.io/todoist-api-typescript/ | 53 | REST API Reference | https://developer.todoist.com/rest/v2/ | 54 | Authorization Guide | https://developer.todoist.com/guides/ | 55 56 ### Basic Usage 57 58 ```typescript 59 import { TodoistApi } from '@doist/todoist-api-typescript' 60 61 const api = new TodoistApi('YOUR_API_TOKEN') 62 63 // Fetch all tasks 64 api.getTasks() 65 .then((tasks) => console.log(tasks)) 66 .catch((error) => console.log(error)) 67 68 // Create a task 69 api.addTask({ 70 content: 'Review documentation', 71 dueString: 'tomorrow at 10am', 72 priority: 4, // P1 = 4, P2 = 3, P3 = 2, P4 = 1 73 projectId: '2203306141' 74 }) 75 ``` 76 77 ### Browser Extension Compatibility 78 79 The SDK supports custom HTTP implementations for environments with specific networking requirements. This is critical for browser extensions: 80 81 ```typescript 82 import { TodoistApi } from '@doist/todoist-api-typescript' 83 84 // Browser extension compatible initialization 85 const api = new TodoistApi('YOUR_TOKEN', { 86 customFetch: async (url, options) => { 87 // Use browser extension's fetch or background script messaging 88 const response = await fetch(url, options) 89 return { 90 ok: response.ok, 91 status: response.status, 92 statusText: response.statusText, 93 headers: Object.fromEntries(response.headers.entries()), 94 text: () => response.text(), 95 json: () => response.json(), 96 } 97 } 98 }) 99 ``` 100 101 --- 102 103 ## Architecture 104 105 ### System Diagram 106 107 ``` 108 ┌─────────────────────────────────────────────────────────────────────────────┐ 109 │ Dashboard Tab │ 110 │ ┌─────────────────────────────────────────────────────────────────────────┐│ 111 │ │ TodoistCard.svelte ││ 112 │ │ ┌─────────────────────────────────────────────────────────────────┐ ││ 113 │ │ │ 📋 Python Research Tasks [+ Add] [⟳] [⚙] │ ││ 114 │ │ │ ───────────────────────────────────────────────────────────────│ ││ 115 │ │ │ ☐ Review asyncio documentation 📅 Today ●●●○ │ ││ 116 │ │ │ ☐ Write unit tests 📅 Tomorrow ●●○○ │ ││ 117 │ │ │ ☑ Set up project structure 📅 Dec 10 ●○○○ │ ││ 118 │ │ │ ───────────────────────────────────────────────────────────────│ ││ 119 │ │ │ [Show completed ▾] 3 of 5 tasks │ ││ 120 │ │ └─────────────────────────────────────────────────────────────────┘ ││ 121 │ └─────────────────────────────────────────────────────────────────────────┘│ 122 └─────────────────────────────────────────────────────────────────────────────┘ 123 │ 124 ▼ 125 ┌─────────────────────────────────────────────────────────────────────────────┐ 126 │ Todoist Service Layer (src/lib/todoist/) │ 127 │ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ 128 │ │ TodoistClient │ │ WorkspaceSync │ │ TaskStore │ │ 129 │ │ (API wrapper) │ │ (project mapping) │ │ (Svelte store) │ │ 130 │ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ │ 131 │ │ │ │ │ 132 │ └──────────────────────┼──────────────────────┘ │ 133 │ ▼ │ 134 │ ┌──────────────────────────────────────────────────────────────────────┐ │ 135 │ │ @doist/todoist-api-typescript (Official SDK) │ │ 136 │ └──────────────────────────────────────────────────────────────────────┘ │ 137 └─────────────────────────────────────────────────────────────────────────────┘ 138 │ 139 ▼ 140 ┌───────────────────────────────┐ 141 │ Todoist REST API v2 │ 142 │ https://api.todoist.com/ │ 143 └───────────────────────────────┘ 144 ``` 145 146 ### Todoist Project Hierarchy 147 148 The extension creates and maintains a project structure mirroring workspaces: 149 150 ``` 151 Todoist Account: 152 └── 📁 Mnemonic ← Master project (auto-created) 153 │ 154 ├── 📁 Python Research ← Parent Workspace 155 │ ├── 📁 AsyncIO Deep Dive ← Child Workspace 156 │ └── 📁 FastAPI Project ← Child Workspace 157 │ 158 ├── 📁 Trading Systems ← Parent Workspace 159 │ ├── 📁 REACT Development ← Child Workspace 160 │ ├── 📁 UAEC Controller ← Child Workspace 161 │ └── 📁 UCAR Risk Management ← Child Workspace 162 │ 163 └── 📁 Browser Extension Dev ← Standalone Workspace 164 ``` 165 166 --- 167 168 ## Type Definitions 169 170 ```typescript 171 // src/lib/todoist/types.ts 172 173 import type { Task, Project, Section, Label, Comment } from '@doist/todoist-api-typescript' 174 175 /** 176 * Extended task with workspace context 177 */ 178 export interface WorkspaceTask extends Task { 179 workspaceId: string 180 workspaceName: string 181 } 182 183 /** 184 * Mapping between workspace and Todoist project 185 */ 186 export interface WorkspaceProjectMapping { 187 workspaceId: string 188 workspaceName: string 189 workspaceType: 'parent' | 'child' | 'standalone' 190 todoistProjectId: string 191 todoistProjectName: string 192 parentProjectId?: string // For child workspaces 193 lastSynced: string // ISO timestamp 194 } 195 196 /** 197 * Todoist connection state 198 */ 199 export interface TodoistConnectionState { 200 connected: boolean 201 apiToken?: string 202 masterProjectId?: string 203 masterProjectName: string 204 lastSync?: string 205 error?: string 206 } 207 208 /** 209 * Task creation request 210 */ 211 export interface CreateTaskRequest { 212 content: string 213 description?: string 214 dueString?: string // Natural language: "tomorrow", "next Monday 3pm" 215 dueDate?: string // ISO date: "2024-12-25" 216 dueDatetime?: string // ISO datetime: "2024-12-25T10:00:00" 217 priority?: 1 | 2 | 3 | 4 // 4 = P1 (highest), 1 = P4 (lowest) 218 labels?: string[] 219 sectionId?: string 220 } 221 222 /** 223 * Task update request 224 */ 225 export interface UpdateTaskRequest { 226 taskId: string 227 content?: string 228 description?: string 229 dueString?: string 230 dueDate?: string 231 dueDatetime?: string 232 priority?: 1 | 2 | 3 | 4 233 labels?: string[] 234 } 235 236 /** 237 * Task filter options 238 */ 239 export interface TaskFilter { 240 showCompleted: boolean 241 priority?: 1 | 2 | 3 | 4 242 dueBefore?: string 243 dueAfter?: string 244 labels?: string[] 245 } 246 247 /** 248 * Sync result 249 */ 250 export interface SyncResult { 251 success: boolean 252 tasksUpdated: number 253 tasksFailed: number 254 projectsCreated: number 255 errors: string[] 256 } 257 258 /** 259 * Priority display configuration 260 */ 261 export const PRIORITY_CONFIG = { 262 4: { label: 'P1', color: '#d1453b', dots: 4 }, // Highest 263 3: { label: 'P2', color: '#eb8909', dots: 3 }, 264 2: { label: 'P3', color: '#246fe0', dots: 2 }, 265 1: { label: 'P4', color: '#666666', dots: 1 }, // Lowest 266 } as const 267 ``` 268 269 --- 270 271 ## Todoist Client Wrapper 272 273 Wraps the official SDK with extension-specific functionality: 274 275 ```typescript 276 // src/lib/todoist/client.ts 277 278 import { TodoistApi } from '@doist/todoist-api-typescript' 279 import type { Task, Project, Section, Label } from '@doist/todoist-api-typescript' 280 import type { 281 TodoistConnectionState, 282 CreateTaskRequest, 283 UpdateTaskRequest, 284 WorkspaceProjectMapping 285 } from './types' 286 import { settingsStore } from '../storage/settings' 287 288 const MASTER_PROJECT_NAME = 'Mnemonic' 289 290 /** 291 * Todoist API client wrapper for browser extension 292 */ 293 export class TodoistClient { 294 private api: TodoistApi | null = null 295 private masterProjectId: string | null = null 296 297 /** 298 * Initialize client with API token 299 */ 300 async initialize(apiToken: string): Promise<TodoistConnectionState> { 301 try { 302 // Create API instance with browser-compatible fetch 303 this.api = new TodoistApi(apiToken, { 304 customFetch: this.createExtensionFetch() 305 }) 306 307 // Verify connection by fetching projects 308 const projects = await this.api.getProjects() 309 310 // Find or create master project 311 let masterProject = projects.find(p => p.name === MASTER_PROJECT_NAME && !p.parentId) 312 313 if (!masterProject) { 314 masterProject = await this.api.addProject({ name: MASTER_PROJECT_NAME }) 315 } 316 317 this.masterProjectId = masterProject.id 318 319 return { 320 connected: true, 321 apiToken, 322 masterProjectId: masterProject.id, 323 masterProjectName: masterProject.name, 324 lastSync: new Date().toISOString(), 325 } 326 } catch (error) { 327 return { 328 connected: false, 329 masterProjectName: MASTER_PROJECT_NAME, 330 error: error instanceof Error ? error.message : 'Connection failed', 331 } 332 } 333 } 334 335 /** 336 * Create browser extension compatible fetch wrapper 337 */ 338 private createExtensionFetch() { 339 return async (url: string, options?: RequestInit & { timeout?: number }) => { 340 const controller = new AbortController() 341 const timeout = options?.timeout ?? 30000 342 343 const timeoutId = setTimeout(() => controller.abort(), timeout) 344 345 try { 346 const response = await fetch(url, { 347 ...options, 348 signal: controller.signal, 349 }) 350 351 return { 352 ok: response.ok, 353 status: response.status, 354 statusText: response.statusText, 355 headers: Object.fromEntries(response.headers.entries()), 356 text: () => response.text(), 357 json: () => response.json(), 358 } 359 } finally { 360 clearTimeout(timeoutId) 361 } 362 } 363 } 364 365 /** 366 * Check if client is connected 367 */ 368 isConnected(): boolean { 369 return this.api !== null && this.masterProjectId !== null 370 } 371 372 /** 373 * Get master project ID 374 */ 375 getMasterProjectId(): string | null { 376 return this.masterProjectId 377 } 378 379 // ───────────────────────────────────────────────────────────────────────── 380 // Project Operations 381 // ───────────────────────────────────────────────────────────────────────── 382 383 /** 384 * Get all projects under master Mnemonic project 385 */ 386 async getWorkspaceProjects(): Promise<Project[]> { 387 if (!this.api || !this.masterProjectId) { 388 throw new Error('Todoist client not initialized') 389 } 390 391 const allProjects = await this.api.getProjects() 392 393 // Filter to only Mnemonic children and grandchildren 394 const mnemonicChildren = allProjects.filter(p => p.parentId === this.masterProjectId) 395 const mnemonicGrandchildren = allProjects.filter(p => 396 mnemonicChildren.some(child => child.id === p.parentId) 397 ) 398 399 return [...mnemonicChildren, ...mnemonicGrandchildren] 400 } 401 402 /** 403 * Create a project for a workspace 404 */ 405 async createWorkspaceProject( 406 workspaceName: string, 407 workspaceType: 'parent' | 'child' | 'standalone', 408 parentWorkspaceProjectId?: string 409 ): Promise<Project> { 410 if (!this.api || !this.masterProjectId) { 411 throw new Error('Todoist client not initialized') 412 } 413 414 const parentId = workspaceType === 'child' && parentWorkspaceProjectId 415 ? parentWorkspaceProjectId 416 : this.masterProjectId 417 418 return await this.api.addProject({ 419 name: workspaceName, 420 parentId, 421 }) 422 } 423 424 /** 425 * Update project name 426 */ 427 async updateProject(projectId: string, name: string): Promise<Project> { 428 if (!this.api) throw new Error('Todoist client not initialized') 429 return await this.api.updateProject(projectId, { name }) 430 } 431 432 /** 433 * Delete a project (and all its tasks) 434 */ 435 async deleteProject(projectId: string): Promise<boolean> { 436 if (!this.api) throw new Error('Todoist client not initialized') 437 return await this.api.deleteProject(projectId) 438 } 439 440 // ───────────────────────────────────────────────────────────────────────── 441 // Task Operations 442 // ───────────────────────────────────────────────────────────────────────── 443 444 /** 445 * Get tasks for a specific project 446 */ 447 async getProjectTasks(projectId: string): Promise<Task[]> { 448 if (!this.api) throw new Error('Todoist client not initialized') 449 return await this.api.getTasks({ projectId }) 450 } 451 452 /** 453 * Get all tasks under Mnemonic 454 */ 455 async getAllWorkspaceTasks(): Promise<Task[]> { 456 if (!this.api || !this.masterProjectId) { 457 throw new Error('Todoist client not initialized') 458 } 459 460 const projects = await this.getWorkspaceProjects() 461 const projectIds = [this.masterProjectId, ...projects.map(p => p.id)] 462 463 const taskPromises = projectIds.map(id => this.api!.getTasks({ projectId: id })) 464 const taskArrays = await Promise.all(taskPromises) 465 466 return taskArrays.flat() 467 } 468 469 /** 470 * Create a new task 471 */ 472 async createTask(projectId: string, request: CreateTaskRequest): Promise<Task> { 473 if (!this.api) throw new Error('Todoist client not initialized') 474 475 return await this.api.addTask({ 476 content: request.content, 477 description: request.description, 478 projectId, 479 dueString: request.dueString, 480 dueDate: request.dueDate, 481 dueDatetime: request.dueDatetime, 482 priority: request.priority, 483 labels: request.labels, 484 sectionId: request.sectionId, 485 }) 486 } 487 488 /** 489 * Update an existing task 490 */ 491 async updateTask(request: UpdateTaskRequest): Promise<Task> { 492 if (!this.api) throw new Error('Todoist client not initialized') 493 494 return await this.api.updateTask(request.taskId, { 495 content: request.content, 496 description: request.description, 497 dueString: request.dueString, 498 dueDate: request.dueDate, 499 dueDatetime: request.dueDatetime, 500 priority: request.priority, 501 labels: request.labels, 502 }) 503 } 504 505 /** 506 * Complete a task 507 */ 508 async completeTask(taskId: string): Promise<boolean> { 509 if (!this.api) throw new Error('Todoist client not initialized') 510 return await this.api.closeTask(taskId) 511 } 512 513 /** 514 * Reopen a completed task 515 */ 516 async reopenTask(taskId: string): Promise<boolean> { 517 if (!this.api) throw new Error('Todoist client not initialized') 518 return await this.api.reopenTask(taskId) 519 } 520 521 /** 522 * Delete a task 523 */ 524 async deleteTask(taskId: string): Promise<boolean> { 525 if (!this.api) throw new Error('Todoist client not initialized') 526 return await this.api.deleteTask(taskId) 527 } 528 529 // ───────────────────────────────────────────────────────────────────────── 530 // Section Operations 531 // ───────────────────────────────────────────────────────────────────────── 532 533 /** 534 * Get sections for a project 535 */ 536 async getProjectSections(projectId: string): Promise<Section[]> { 537 if (!this.api) throw new Error('Todoist client not initialized') 538 return await this.api.getSections(projectId) 539 } 540 541 /** 542 * Create a section 543 */ 544 async createSection(projectId: string, name: string): Promise<Section> { 545 if (!this.api) throw new Error('Todoist client not initialized') 546 return await this.api.addSection({ name, projectId }) 547 } 548 549 // ───────────────────────────────────────────────────────────────────────── 550 // Label Operations 551 // ───────────────────────────────────────────────────────────────────────── 552 553 /** 554 * Get all labels 555 */ 556 async getLabels(): Promise<Label[]> { 557 if (!this.api) throw new Error('Todoist client not initialized') 558 return await this.api.getLabels() 559 } 560 561 /** 562 * Create a label 563 */ 564 async createLabel(name: string, color?: string): Promise<Label> { 565 if (!this.api) throw new Error('Todoist client not initialized') 566 return await this.api.addLabel({ name, color }) 567 } 568 } 569 570 // Singleton instance 571 export const todoistClient = new TodoistClient() 572 ``` 573 574 --- 575 576 ## Workspace Sync Service 577 578 Manages the mapping between browser workspaces and Todoist projects: 579 580 ```typescript 581 // src/lib/todoist/workspace-sync.ts 582 583 import { todoistClient } from './client' 584 import type { Project } from '@doist/todoist-api-typescript' 585 import type { Workspace, ParentWorkspace, ChildWorkspace } from '../types/workspace' 586 import type { WorkspaceProjectMapping, SyncResult } from './types' 587 import { storage } from '../storage' 588 589 const MAPPING_STORAGE_KEY = 'todoist_workspace_mappings' 590 591 /** 592 * Manages synchronization between workspaces and Todoist projects 593 */ 594 export class WorkspaceSyncService { 595 private mappings: Map<string, WorkspaceProjectMapping> = new Map() 596 597 /** 598 * Load mappings from storage 599 */ 600 async loadMappings(): Promise<void> { 601 const stored = await storage.get<WorkspaceProjectMapping[]>(MAPPING_STORAGE_KEY) 602 if (stored) { 603 this.mappings = new Map(stored.map(m => [m.workspaceId, m])) 604 } 605 } 606 607 /** 608 * Save mappings to storage 609 */ 610 private async saveMappings(): Promise<void> { 611 const mappingsArray = Array.from(this.mappings.values()) 612 await storage.set(MAPPING_STORAGE_KEY, mappingsArray) 613 } 614 615 /** 616 * Get Todoist project ID for a workspace 617 */ 618 getProjectId(workspaceId: string): string | undefined { 619 return this.mappings.get(workspaceId)?.todoistProjectId 620 } 621 622 /** 623 * Get workspace ID for a Todoist project 624 */ 625 getWorkspaceId(projectId: string): string | undefined { 626 for (const mapping of this.mappings.values()) { 627 if (mapping.todoistProjectId === projectId) { 628 return mapping.workspaceId 629 } 630 } 631 return undefined 632 } 633 634 /** 635 * Sync a single workspace to Todoist 636 * Creates project if it doesn't exist, updates name if changed 637 */ 638 async syncWorkspace( 639 workspace: Workspace, 640 parentProjectId?: string 641 ): Promise<WorkspaceProjectMapping> { 642 const existing = this.mappings.get(workspace.id) 643 644 if (existing) { 645 // Check if name changed 646 if (existing.workspaceName !== workspace.name) { 647 await todoistClient.updateProject(existing.todoistProjectId, workspace.name) 648 existing.workspaceName = workspace.name 649 existing.lastSynced = new Date().toISOString() 650 await this.saveMappings() 651 } 652 return existing 653 } 654 655 // Create new project 656 const project = await todoistClient.createWorkspaceProject( 657 workspace.name, 658 workspace.type, 659 parentProjectId 660 ) 661 662 const mapping: WorkspaceProjectMapping = { 663 workspaceId: workspace.id, 664 workspaceName: workspace.name, 665 workspaceType: workspace.type, 666 todoistProjectId: project.id, 667 todoistProjectName: project.name, 668 parentProjectId, 669 lastSynced: new Date().toISOString(), 670 } 671 672 this.mappings.set(workspace.id, mapping) 673 await this.saveMappings() 674 675 return mapping 676 } 677 678 /** 679 * Sync all workspaces to Todoist 680 * Handles parent/child relationships correctly 681 */ 682 async syncAllWorkspaces(workspaces: Workspace[]): Promise<SyncResult> { 683 const result: SyncResult = { 684 success: true, 685 tasksUpdated: 0, 686 tasksFailed: 0, 687 projectsCreated: 0, 688 errors: [], 689 } 690 691 // Separate by type for ordered processing 692 const parents = workspaces.filter(w => w.type === 'parent') as ParentWorkspace[] 693 const standalones = workspaces.filter(w => w.type === 'standalone') 694 695 // Sync parent workspaces first 696 for (const parent of parents) { 697 try { 698 const mapping = await this.syncWorkspace(parent) 699 if (!this.mappings.has(parent.id)) { 700 result.projectsCreated++ 701 } 702 703 // Sync child workspaces under this parent 704 for (const child of parent.children) { 705 try { 706 await this.syncWorkspace(child, mapping.todoistProjectId) 707 if (!this.mappings.has(child.id)) { 708 result.projectsCreated++ 709 } 710 } catch (error) { 711 result.errors.push(`Failed to sync child workspace ${child.name}: ${error}`) 712 result.success = false 713 } 714 } 715 } catch (error) { 716 result.errors.push(`Failed to sync parent workspace ${parent.name}: ${error}`) 717 result.success = false 718 } 719 } 720 721 // Sync standalone workspaces 722 for (const standalone of standalones) { 723 try { 724 await this.syncWorkspace(standalone) 725 if (!this.mappings.has(standalone.id)) { 726 result.projectsCreated++ 727 } 728 } catch (error) { 729 result.errors.push(`Failed to sync standalone workspace ${standalone.name}: ${error}`) 730 result.success = false 731 } 732 } 733 734 return result 735 } 736 737 /** 738 * Remove mapping when workspace is deleted 739 */ 740 async removeMapping(workspaceId: string, deleteProject: boolean = false): Promise<void> { 741 const mapping = this.mappings.get(workspaceId) 742 743 if (mapping) { 744 if (deleteProject) { 745 try { 746 await todoistClient.deleteProject(mapping.todoistProjectId) 747 } catch (error) { 748 console.error('Failed to delete Todoist project:', error) 749 } 750 } 751 752 this.mappings.delete(workspaceId) 753 await this.saveMappings() 754 } 755 } 756 757 /** 758 * Get all mappings 759 */ 760 getAllMappings(): WorkspaceProjectMapping[] { 761 return Array.from(this.mappings.values()) 762 } 763 764 /** 765 * Reconcile mappings with current Todoist projects 766 * Removes orphaned mappings where project no longer exists 767 */ 768 async reconcile(): Promise<number> { 769 const projects = await todoistClient.getWorkspaceProjects() 770 const projectIds = new Set(projects.map(p => p.id)) 771 772 let removedCount = 0 773 774 for (const [workspaceId, mapping] of this.mappings) { 775 if (!projectIds.has(mapping.todoistProjectId)) { 776 this.mappings.delete(workspaceId) 777 removedCount++ 778 } 779 } 780 781 if (removedCount > 0) { 782 await this.saveMappings() 783 } 784 785 return removedCount 786 } 787 } 788 789 export const workspaceSyncService = new WorkspaceSyncService() 790 ``` 791 792 --- 793 794 ## Task Store (Svelte) 795 796 Reactive store for task state management: 797 798 ```typescript 799 // src/lib/todoist/task-store.ts 800 801 import { writable, derived, get } from 'svelte/store' 802 import type { Task } from '@doist/todoist-api-typescript' 803 import type { WorkspaceTask, TaskFilter, CreateTaskRequest, UpdateTaskRequest } from './types' 804 import { todoistClient } from './client' 805 import { workspaceSyncService } from './workspace-sync' 806 807 /** 808 * Create the task store with CRUD operations 809 */ 810 function createTaskStore() { 811 const tasks = writable<Map<string, WorkspaceTask>>(new Map()) 812 const loading = writable<boolean>(false) 813 const error = writable<string | null>(null) 814 const filter = writable<TaskFilter>({ showCompleted: false }) 815 816 /** 817 * Enrich task with workspace context 818 */ 819 function enrichTask(task: Task): WorkspaceTask | null { 820 const projectId = task.projectId 821 const workspaceId = workspaceSyncService.getWorkspaceId(projectId) 822 const mapping = workspaceSyncService.getAllMappings().find(m => m.todoistProjectId === projectId) 823 824 if (!workspaceId || !mapping) { 825 return null 826 } 827 828 return { 829 ...task, 830 workspaceId, 831 workspaceName: mapping.workspaceName, 832 } 833 } 834 835 return { 836 subscribe: tasks.subscribe, 837 loading, 838 error, 839 filter, 840 841 /** 842 * Load tasks for a specific workspace 843 */ 844 async loadWorkspaceTasks(workspaceId: string): Promise<void> { 845 const projectId = workspaceSyncService.getProjectId(workspaceId) 846 847 if (!projectId) { 848 error.set('Workspace not synced to Todoist') 849 return 850 } 851 852 loading.set(true) 853 error.set(null) 854 855 try { 856 const rawTasks = await todoistClient.getProjectTasks(projectId) 857 const enrichedTasks = rawTasks 858 .map(t => enrichTask(t)) 859 .filter((t): t is WorkspaceTask => t !== null) 860 861 tasks.update(current => { 862 // Clear existing tasks for this workspace 863 for (const [id, task] of current) { 864 if (task.workspaceId === workspaceId) { 865 current.delete(id) 866 } 867 } 868 // Add new tasks 869 for (const task of enrichedTasks) { 870 current.set(task.id, task) 871 } 872 return new Map(current) 873 }) 874 } catch (e) { 875 error.set(e instanceof Error ? e.message : 'Failed to load tasks') 876 } finally { 877 loading.set(false) 878 } 879 }, 880 881 /** 882 * Load all tasks across all workspaces 883 */ 884 async loadAllTasks(): Promise<void> { 885 loading.set(true) 886 error.set(null) 887 888 try { 889 const rawTasks = await todoistClient.getAllWorkspaceTasks() 890 const enrichedTasks = rawTasks 891 .map(t => enrichTask(t)) 892 .filter((t): t is WorkspaceTask => t !== null) 893 894 tasks.set(new Map(enrichedTasks.map(t => [t.id, t]))) 895 } catch (e) { 896 error.set(e instanceof Error ? e.message : 'Failed to load tasks') 897 } finally { 898 loading.set(false) 899 } 900 }, 901 902 /** 903 * Create a new task 904 */ 905 async createTask(workspaceId: string, request: CreateTaskRequest): Promise<WorkspaceTask | null> { 906 const projectId = workspaceSyncService.getProjectId(workspaceId) 907 908 if (!projectId) { 909 error.set('Workspace not synced to Todoist') 910 return null 911 } 912 913 try { 914 const task = await todoistClient.createTask(projectId, request) 915 const enriched = enrichTask(task) 916 917 if (enriched) { 918 tasks.update(current => { 919 current.set(enriched.id, enriched) 920 return new Map(current) 921 }) 922 } 923 924 return enriched 925 } catch (e) { 926 error.set(e instanceof Error ? e.message : 'Failed to create task') 927 return null 928 } 929 }, 930 931 /** 932 * Update a task 933 */ 934 async updateTask(request: UpdateTaskRequest): Promise<WorkspaceTask | null> { 935 try { 936 const task = await todoistClient.updateTask(request) 937 const enriched = enrichTask(task) 938 939 if (enriched) { 940 tasks.update(current => { 941 current.set(enriched.id, enriched) 942 return new Map(current) 943 }) 944 } 945 946 return enriched 947 } catch (e) { 948 error.set(e instanceof Error ? e.message : 'Failed to update task') 949 return null 950 } 951 }, 952 953 /** 954 * Toggle task completion 955 */ 956 async toggleComplete(taskId: string): Promise<boolean> { 957 const currentTasks = get(tasks) 958 const task = currentTasks.get(taskId) 959 960 if (!task) return false 961 962 try { 963 if (task.isCompleted) { 964 await todoistClient.reopenTask(taskId) 965 } else { 966 await todoistClient.completeTask(taskId) 967 } 968 969 tasks.update(current => { 970 const updated = current.get(taskId) 971 if (updated) { 972 updated.isCompleted = !updated.isCompleted 973 current.set(taskId, { ...updated }) 974 } 975 return new Map(current) 976 }) 977 978 return true 979 } catch (e) { 980 error.set(e instanceof Error ? e.message : 'Failed to update task') 981 return false 982 } 983 }, 984 985 /** 986 * Delete a task 987 */ 988 async deleteTask(taskId: string): Promise<boolean> { 989 try { 990 await todoistClient.deleteTask(taskId) 991 992 tasks.update(current => { 993 current.delete(taskId) 994 return new Map(current) 995 }) 996 997 return true 998 } catch (e) { 999 error.set(e instanceof Error ? e.message : 'Failed to delete task') 1000 return false 1001 } 1002 }, 1003 1004 /** 1005 * Clear error 1006 */ 1007 clearError(): void { 1008 error.set(null) 1009 }, 1010 } 1011 } 1012 1013 export const taskStore = createTaskStore() 1014 1015 /** 1016 * Derived store for filtered tasks by workspace 1017 */ 1018 export function getWorkspaceTasks(workspaceId: string) { 1019 return derived( 1020 [taskStore, taskStore.filter], 1021 ([$tasks, $filter]) => { 1022 const filtered: WorkspaceTask[] = [] 1023 1024 for (const task of $tasks.values()) { 1025 if (task.workspaceId !== workspaceId) continue 1026 if (!$filter.showCompleted && task.isCompleted) continue 1027 if ($filter.priority && task.priority !== $filter.priority) continue 1028 1029 filtered.push(task) 1030 } 1031 1032 // Sort: incomplete first, then by due date, then by priority 1033 return filtered.sort((a, b) => { 1034 // Completed at bottom 1035 if (a.isCompleted !== b.isCompleted) { 1036 return a.isCompleted ? 1 : -1 1037 } 1038 1039 // Then by due date (null dates at end) 1040 if (a.due?.date !== b.due?.date) { 1041 if (!a.due?.date) return 1 1042 if (!b.due?.date) return -1 1043 return a.due.date.localeCompare(b.due.date) 1044 } 1045 1046 // Then by priority (higher number = higher priority) 1047 return (b.priority || 1) - (a.priority || 1) 1048 }) 1049 } 1050 ) 1051 } 1052 ``` 1053 1054 --- 1055 1056 ## Svelte Components 1057 1058 ### TodoistCard Component 1059 1060 Main dashboard card component: 1061 1062 ```svelte 1063 <!-- src/components/todoist/TodoistCard.svelte --> 1064 <script lang="ts"> 1065 import { onMount } from 'svelte' 1066 import { taskStore, getWorkspaceTasks } from '../../lib/todoist/task-store' 1067 import { todoistClient } from '../../lib/todoist/client' 1068 import { workspaceSyncService } from '../../lib/todoist/workspace-sync' 1069 import type { Workspace } from '../../lib/types/workspace' 1070 import type { CreateTaskRequest } from '../../lib/todoist/types' 1071 import { PRIORITY_CONFIG } from '../../lib/todoist/types' 1072 import TaskItem from './TaskItem.svelte' 1073 import TaskEditor from './TaskEditor.svelte' 1074 import TodoistSettings from './TodoistSettings.svelte' 1075 1076 export let workspace: Workspace 1077 1078 let showAddTask = false 1079 let showSettings = false 1080 let syncing = false 1081 1082 $: tasks = getWorkspaceTasks(workspace.id) 1083 $: isConnected = todoistClient.isConnected() 1084 $: projectId = workspaceSyncService.getProjectId(workspace.id) 1085 1086 onMount(async () => { 1087 if (isConnected && projectId) { 1088 await taskStore.loadWorkspaceTasks(workspace.id) 1089 } 1090 }) 1091 1092 async function handleSync(): Promise<void> { 1093 if (!isConnected) return 1094 1095 syncing = true 1096 try { 1097 await workspaceSyncService.syncWorkspace(workspace) 1098 await taskStore.loadWorkspaceTasks(workspace.id) 1099 } finally { 1100 syncing = false 1101 } 1102 } 1103 1104 async function handleCreateTask(event: CustomEvent<CreateTaskRequest>): Promise<void> { 1105 const task = await taskStore.createTask(workspace.id, event.detail) 1106 if (task) { 1107 showAddTask = false 1108 } 1109 } 1110 1111 function toggleShowCompleted(): void { 1112 taskStore.filter.update(f => ({ ...f, showCompleted: !f.showCompleted })) 1113 } 1114 </script> 1115 1116 <div class="todoist-card"> 1117 <header class="card-header"> 1118 <div class="header-left"> 1119 <span class="card-icon">📋</span> 1120 <h3>{workspace.name} Tasks</h3> 1121 </div> 1122 1123 <div class="header-actions"> 1124 {#if isConnected} 1125 <button 1126 class="btn-icon" 1127 on:click={() => showAddTask = true} 1128 title="Add task" 1129 > 1130 + 1131 </button> 1132 <button 1133 class="btn-icon" 1134 class:syncing 1135 on:click={handleSync} 1136 title="Sync with Todoist" 1137 > 1138 ⟳ 1139 </button> 1140 {/if} 1141 <button 1142 class="btn-icon" 1143 on:click={() => showSettings = true} 1144 title="Settings" 1145 > 1146 ⚙ 1147 </button> 1148 </div> 1149 </header> 1150 1151 {#if !isConnected} 1152 <div class="not-connected"> 1153 <p>Connect to Todoist to manage tasks</p> 1154 <button class="btn-primary" on:click={() => showSettings = true}> 1155 Connect 1156 </button> 1157 </div> 1158 {:else if $taskStore.loading} 1159 <div class="loading"> 1160 <span class="spinner"></span> 1161 Loading tasks... 1162 </div> 1163 {:else if $taskStore.error} 1164 <div class="error"> 1165 <p>{$taskStore.error}</p> 1166 <button on:click={() => taskStore.clearError()}>Dismiss</button> 1167 </div> 1168 {:else} 1169 {#if showAddTask} 1170 <TaskEditor 1171 on:save={handleCreateTask} 1172 on:cancel={() => showAddTask = false} 1173 /> 1174 {/if} 1175 1176 <div class="task-list"> 1177 {#each $tasks as task (task.id)} 1178 <TaskItem {task} /> 1179 {:else} 1180 <div class="empty-state"> 1181 <p>No tasks yet</p> 1182 <button class="btn-secondary" on:click={() => showAddTask = true}> 1183 Add your first task 1184 </button> 1185 </div> 1186 {/each} 1187 </div> 1188 1189 <footer class="card-footer"> 1190 <button 1191 class="btn-text" 1192 on:click={toggleShowCompleted} 1193 > 1194 {$taskStore.filter.showCompleted ? 'Hide' : 'Show'} completed 1195 </button> 1196 <span class="task-count"> 1197 {$tasks.filter(t => !t.isCompleted).length} active 1198 </span> 1199 </footer> 1200 {/if} 1201 </div> 1202 1203 {#if showSettings} 1204 <TodoistSettings on:close={() => showSettings = false} /> 1205 {/if} 1206 1207 <style> 1208 .todoist-card { 1209 background: var(--bg-primary); 1210 border: 1px solid var(--border-color); 1211 border-radius: 8px; 1212 overflow: hidden; 1213 } 1214 1215 .card-header { 1216 display: flex; 1217 align-items: center; 1218 justify-content: space-between; 1219 padding: 0.75rem 1rem; 1220 border-bottom: 1px solid var(--border-color); 1221 background: var(--bg-secondary); 1222 } 1223 1224 .header-left { 1225 display: flex; 1226 align-items: center; 1227 gap: 0.5rem; 1228 } 1229 1230 .card-icon { 1231 font-size: 1.25rem; 1232 } 1233 1234 .card-header h3 { 1235 margin: 0; 1236 font-size: 0.9rem; 1237 font-weight: 600; 1238 } 1239 1240 .header-actions { 1241 display: flex; 1242 gap: 0.25rem; 1243 } 1244 1245 .btn-icon { 1246 width: 28px; 1247 height: 28px; 1248 border: none; 1249 background: transparent; 1250 border-radius: 4px; 1251 cursor: pointer; 1252 font-size: 1rem; 1253 display: flex; 1254 align-items: center; 1255 justify-content: center; 1256 color: var(--text-secondary); 1257 } 1258 1259 .btn-icon:hover { 1260 background: var(--bg-hover); 1261 color: var(--text-primary); 1262 } 1263 1264 .btn-icon.syncing { 1265 animation: spin 1s linear infinite; 1266 } 1267 1268 @keyframes spin { 1269 from { transform: rotate(0deg); } 1270 to { transform: rotate(360deg); } 1271 } 1272 1273 .not-connected, 1274 .loading, 1275 .error, 1276 .empty-state { 1277 padding: 2rem; 1278 text-align: center; 1279 color: var(--text-secondary); 1280 } 1281 1282 .task-list { 1283 max-height: 400px; 1284 overflow-y: auto; 1285 } 1286 1287 .card-footer { 1288 display: flex; 1289 align-items: center; 1290 justify-content: space-between; 1291 padding: 0.5rem 1rem; 1292 border-top: 1px solid var(--border-color); 1293 font-size: 0.8rem; 1294 } 1295 1296 .btn-text { 1297 background: none; 1298 border: none; 1299 color: var(--text-secondary); 1300 cursor: pointer; 1301 padding: 0.25rem 0.5rem; 1302 } 1303 1304 .btn-text:hover { 1305 color: var(--text-primary); 1306 } 1307 1308 .task-count { 1309 color: var(--text-secondary); 1310 } 1311 1312 .btn-primary { 1313 background: var(--accent-color); 1314 color: white; 1315 border: none; 1316 padding: 0.5rem 1rem; 1317 border-radius: 4px; 1318 cursor: pointer; 1319 } 1320 1321 .btn-secondary { 1322 background: var(--bg-secondary); 1323 border: 1px solid var(--border-color); 1324 padding: 0.5rem 1rem; 1325 border-radius: 4px; 1326 cursor: pointer; 1327 } 1328 1329 .spinner { 1330 display: inline-block; 1331 width: 16px; 1332 height: 16px; 1333 border: 2px solid var(--border-color); 1334 border-top-color: var(--accent-color); 1335 border-radius: 50%; 1336 animation: spin 0.8s linear infinite; 1337 margin-right: 0.5rem; 1338 } 1339 </style> 1340 ``` 1341 1342 ### TaskItem Component 1343 1344 Individual task display: 1345 1346 ```svelte 1347 <!-- src/components/todoist/TaskItem.svelte --> 1348 <script lang="ts"> 1349 import { createEventDispatcher } from 'svelte' 1350 import type { WorkspaceTask } from '../../lib/todoist/types' 1351 import { PRIORITY_CONFIG } from '../../lib/todoist/types' 1352 import { taskStore } from '../../lib/todoist/task-store' 1353 import TaskEditor from './TaskEditor.svelte' 1354 1355 export let task: WorkspaceTask 1356 1357 const dispatch = createEventDispatcher() 1358 1359 let editing = false 1360 let completing = false 1361 1362 $: priorityConfig = PRIORITY_CONFIG[task.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG[1] 1363 $: dueDisplay = formatDue(task.due) 1364 $: isOverdue = task.due?.date && new Date(task.due.date) < new Date() && !task.isCompleted 1365 1366 function formatDue(due: WorkspaceTask['due']): string { 1367 if (!due?.date) return '' 1368 1369 const date = new Date(due.date) 1370 const today = new Date() 1371 const tomorrow = new Date(today) 1372 tomorrow.setDate(tomorrow.getDate() + 1) 1373 1374 if (date.toDateString() === today.toDateString()) { 1375 return 'Today' 1376 } 1377 if (date.toDateString() === tomorrow.toDateString()) { 1378 return 'Tomorrow' 1379 } 1380 1381 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) 1382 } 1383 1384 async function handleToggleComplete(): Promise<void> { 1385 completing = true 1386 await taskStore.toggleComplete(task.id) 1387 completing = false 1388 } 1389 1390 async function handleDelete(): Promise<void> { 1391 if (confirm('Delete this task?')) { 1392 await taskStore.deleteTask(task.id) 1393 } 1394 } 1395 </script> 1396 1397 {#if editing} 1398 <TaskEditor 1399 {task} 1400 on:save={() => editing = false} 1401 on:cancel={() => editing = false} 1402 /> 1403 {:else} 1404 <div 1405 class="task-item" 1406 class:completed={task.isCompleted} 1407 class:overdue={isOverdue} 1408 > 1409 <button 1410 class="checkbox" 1411 class:checked={task.isCompleted} 1412 class:completing 1413 style="--priority-color: {priorityConfig.color}" 1414 on:click={handleToggleComplete} 1415 disabled={completing} 1416 > 1417 {#if task.isCompleted} 1418 ✓ 1419 {/if} 1420 </button> 1421 1422 <div class="task-content" on:dblclick={() => editing = true}> 1423 <span class="task-text">{task.content}</span> 1424 1425 {#if task.description} 1426 <span class="task-description">{task.description}</span> 1427 {/if} 1428 1429 <div class="task-meta"> 1430 {#if dueDisplay} 1431 <span class="due-date" class:overdue={isOverdue}> 1432 📅 {dueDisplay} 1433 </span> 1434 {/if} 1435 1436 {#if task.labels?.length} 1437 <span class="labels"> 1438 {#each task.labels as label} 1439 <span class="label">@{label}</span> 1440 {/each} 1441 </span> 1442 {/if} 1443 </div> 1444 </div> 1445 1446 <div class="priority-indicator" title={priorityConfig.label}> 1447 {#each Array(4) as _, i} 1448 <span 1449 class="dot" 1450 class:filled={i < priorityConfig.dots} 1451 style="--dot-color: {priorityConfig.color}" 1452 ></span> 1453 {/each} 1454 </div> 1455 1456 <div class="task-actions"> 1457 <button class="btn-action" on:click={() => editing = true} title="Edit"> 1458 ✏️ 1459 </button> 1460 <button class="btn-action" on:click={handleDelete} title="Delete"> 1461 🗑️ 1462 </button> 1463 </div> 1464 </div> 1465 {/if} 1466 1467 <style> 1468 .task-item { 1469 display: flex; 1470 align-items: flex-start; 1471 gap: 0.75rem; 1472 padding: 0.75rem 1rem; 1473 border-bottom: 1px solid var(--border-color); 1474 transition: background-color 0.15s; 1475 } 1476 1477 .task-item:hover { 1478 background: var(--bg-hover); 1479 } 1480 1481 .task-item.completed { 1482 opacity: 0.6; 1483 } 1484 1485 .task-item.overdue { 1486 background: rgba(209, 69, 59, 0.05); 1487 } 1488 1489 .checkbox { 1490 width: 20px; 1491 height: 20px; 1492 min-width: 20px; 1493 border: 2px solid var(--priority-color); 1494 border-radius: 50%; 1495 background: transparent; 1496 cursor: pointer; 1497 display: flex; 1498 align-items: center; 1499 justify-content: center; 1500 font-size: 12px; 1501 color: var(--priority-color); 1502 transition: all 0.15s; 1503 } 1504 1505 .checkbox:hover { 1506 background: var(--priority-color); 1507 color: white; 1508 } 1509 1510 .checkbox.checked { 1511 background: var(--priority-color); 1512 color: white; 1513 } 1514 1515 .checkbox.completing { 1516 opacity: 0.5; 1517 } 1518 1519 .task-content { 1520 flex: 1; 1521 min-width: 0; 1522 } 1523 1524 .task-text { 1525 display: block; 1526 font-size: 0.9rem; 1527 } 1528 1529 .completed .task-text { 1530 text-decoration: line-through; 1531 } 1532 1533 .task-description { 1534 display: block; 1535 font-size: 0.8rem; 1536 color: var(--text-secondary); 1537 margin-top: 0.25rem; 1538 white-space: nowrap; 1539 overflow: hidden; 1540 text-overflow: ellipsis; 1541 } 1542 1543 .task-meta { 1544 display: flex; 1545 gap: 0.5rem; 1546 margin-top: 0.25rem; 1547 flex-wrap: wrap; 1548 } 1549 1550 .due-date { 1551 font-size: 0.75rem; 1552 color: var(--text-secondary); 1553 } 1554 1555 .due-date.overdue { 1556 color: #d1453b; 1557 font-weight: 500; 1558 } 1559 1560 .labels { 1561 display: flex; 1562 gap: 0.25rem; 1563 } 1564 1565 .label { 1566 font-size: 0.7rem; 1567 color: var(--accent-color); 1568 background: rgba(99, 102, 241, 0.1); 1569 padding: 0.1rem 0.3rem; 1570 border-radius: 3px; 1571 } 1572 1573 .priority-indicator { 1574 display: flex; 1575 gap: 2px; 1576 align-items: center; 1577 padding-top: 2px; 1578 } 1579 1580 .dot { 1581 width: 6px; 1582 height: 6px; 1583 border-radius: 50%; 1584 background: var(--border-color); 1585 } 1586 1587 .dot.filled { 1588 background: var(--dot-color); 1589 } 1590 1591 .task-actions { 1592 display: flex; 1593 gap: 0.25rem; 1594 opacity: 0; 1595 transition: opacity 0.15s; 1596 } 1597 1598 .task-item:hover .task-actions { 1599 opacity: 1; 1600 } 1601 1602 .btn-action { 1603 width: 24px; 1604 height: 24px; 1605 border: none; 1606 background: transparent; 1607 cursor: pointer; 1608 border-radius: 4px; 1609 font-size: 0.75rem; 1610 } 1611 1612 .btn-action:hover { 1613 background: var(--bg-secondary); 1614 } 1615 </style> 1616 ``` 1617 1618 ### TaskEditor Component 1619 1620 Create/edit task form: 1621 1622 ```svelte 1623 <!-- src/components/todoist/TaskEditor.svelte --> 1624 <script lang="ts"> 1625 import { createEventDispatcher } from 'svelte' 1626 import type { WorkspaceTask, CreateTaskRequest, UpdateTaskRequest } from '../../lib/todoist/types' 1627 import { PRIORITY_CONFIG } from '../../lib/todoist/types' 1628 import { taskStore } from '../../lib/todoist/task-store' 1629 1630 export let task: WorkspaceTask | null = null 1631 1632 const dispatch = createEventDispatcher<{ 1633 save: CreateTaskRequest | UpdateTaskRequest 1634 cancel: void 1635 }>() 1636 1637 let content = task?.content || '' 1638 let description = task?.description || '' 1639 let dueString = task?.due?.string || '' 1640 let priority: 1 | 2 | 3 | 4 = (task?.priority as 1 | 2 | 3 | 4) || 1 1641 let saving = false 1642 1643 $: isValid = content.trim().length > 0 1644 $: isEditing = task !== null 1645 1646 async function handleSubmit(): Promise<void> { 1647 if (!isValid) return 1648 1649 saving = true 1650 1651 try { 1652 if (isEditing && task) { 1653 // Update existing task 1654 await taskStore.updateTask({ 1655 taskId: task.id, 1656 content, 1657 description: description || undefined, 1658 dueString: dueString || undefined, 1659 priority, 1660 }) 1661 dispatch('save') 1662 } else { 1663 // Create new task 1664 dispatch('save', { 1665 content, 1666 description: description || undefined, 1667 dueString: dueString || undefined, 1668 priority, 1669 }) 1670 } 1671 } finally { 1672 saving = false 1673 } 1674 } 1675 1676 function handleKeydown(e: KeyboardEvent): void { 1677 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { 1678 handleSubmit() 1679 } 1680 if (e.key === 'Escape') { 1681 dispatch('cancel') 1682 } 1683 } 1684 </script> 1685 1686 <div class="task-editor" on:keydown={handleKeydown}> 1687 <input 1688 type="text" 1689 class="task-input" 1690 placeholder="Task name" 1691 bind:value={content} 1692 autofocus 1693 /> 1694 1695 <textarea 1696 class="description-input" 1697 placeholder="Description (optional)" 1698 bind:value={description} 1699 rows="2" 1700 ></textarea> 1701 1702 <div class="editor-row"> 1703 <div class="field"> 1704 <label>Due</label> 1705 <input 1706 type="text" 1707 class="due-input" 1708 placeholder="e.g., tomorrow, next Monday" 1709 bind:value={dueString} 1710 /> 1711 </div> 1712 1713 <div class="field"> 1714 <label>Priority</label> 1715 <div class="priority-buttons"> 1716 {#each [4, 3, 2, 1] as p} 1717 {@const config = PRIORITY_CONFIG[p as keyof typeof PRIORITY_CONFIG]} 1718 <button 1719 type="button" 1720 class="priority-btn" 1721 class:selected={priority === p} 1722 style="--priority-color: {config.color}" 1723 on:click={() => priority = p as 1 | 2 | 3 | 4} 1724 > 1725 {config.label} 1726 </button> 1727 {/each} 1728 </div> 1729 </div> 1730 </div> 1731 1732 <div class="editor-actions"> 1733 <button 1734 type="button" 1735 class="btn-cancel" 1736 on:click={() => dispatch('cancel')} 1737 > 1738 Cancel 1739 </button> 1740 <button 1741 type="button" 1742 class="btn-save" 1743 disabled={!isValid || saving} 1744 on:click={handleSubmit} 1745 > 1746 {saving ? 'Saving...' : isEditing ? 'Save' : 'Add Task'} 1747 </button> 1748 </div> 1749 1750 <div class="hint"> 1751 <kbd>⌘</kbd>+<kbd>Enter</kbd> to save, <kbd>Esc</kbd> to cancel 1752 </div> 1753 </div> 1754 1755 <style> 1756 .task-editor { 1757 padding: 1rem; 1758 border-bottom: 1px solid var(--border-color); 1759 background: var(--bg-secondary); 1760 } 1761 1762 .task-input { 1763 width: 100%; 1764 padding: 0.5rem; 1765 border: 1px solid var(--border-color); 1766 border-radius: 4px; 1767 font-size: 0.9rem; 1768 background: var(--bg-primary); 1769 } 1770 1771 .description-input { 1772 width: 100%; 1773 padding: 0.5rem; 1774 border: 1px solid var(--border-color); 1775 border-radius: 4px; 1776 font-size: 0.8rem; 1777 background: var(--bg-primary); 1778 margin-top: 0.5rem; 1779 resize: vertical; 1780 font-family: inherit; 1781 } 1782 1783 .editor-row { 1784 display: flex; 1785 gap: 1rem; 1786 margin-top: 0.75rem; 1787 } 1788 1789 .field { 1790 flex: 1; 1791 } 1792 1793 .field label { 1794 display: block; 1795 font-size: 0.75rem; 1796 font-weight: 500; 1797 color: var(--text-secondary); 1798 margin-bottom: 0.25rem; 1799 } 1800 1801 .due-input { 1802 width: 100%; 1803 padding: 0.4rem 0.5rem; 1804 border: 1px solid var(--border-color); 1805 border-radius: 4px; 1806 font-size: 0.8rem; 1807 background: var(--bg-primary); 1808 } 1809 1810 .priority-buttons { 1811 display: flex; 1812 gap: 0.25rem; 1813 } 1814 1815 .priority-btn { 1816 flex: 1; 1817 padding: 0.35rem 0.5rem; 1818 border: 1px solid var(--border-color); 1819 border-radius: 4px; 1820 background: var(--bg-primary); 1821 cursor: pointer; 1822 font-size: 0.75rem; 1823 font-weight: 500; 1824 color: var(--text-secondary); 1825 transition: all 0.15s; 1826 } 1827 1828 .priority-btn:hover { 1829 border-color: var(--priority-color); 1830 } 1831 1832 .priority-btn.selected { 1833 background: var(--priority-color); 1834 border-color: var(--priority-color); 1835 color: white; 1836 } 1837 1838 .editor-actions { 1839 display: flex; 1840 justify-content: flex-end; 1841 gap: 0.5rem; 1842 margin-top: 0.75rem; 1843 } 1844 1845 .btn-cancel { 1846 padding: 0.4rem 0.75rem; 1847 border: 1px solid var(--border-color); 1848 border-radius: 4px; 1849 background: var(--bg-primary); 1850 cursor: pointer; 1851 font-size: 0.8rem; 1852 } 1853 1854 .btn-save { 1855 padding: 0.4rem 0.75rem; 1856 border: none; 1857 border-radius: 4px; 1858 background: var(--accent-color); 1859 color: white; 1860 cursor: pointer; 1861 font-size: 0.8rem; 1862 } 1863 1864 .btn-save:disabled { 1865 opacity: 0.5; 1866 cursor: not-allowed; 1867 } 1868 1869 .hint { 1870 margin-top: 0.5rem; 1871 font-size: 0.7rem; 1872 color: var(--text-muted); 1873 text-align: right; 1874 } 1875 1876 kbd { 1877 display: inline-block; 1878 padding: 0.1rem 0.3rem; 1879 font-family: monospace; 1880 font-size: 0.65rem; 1881 background: var(--bg-primary); 1882 border: 1px solid var(--border-color); 1883 border-radius: 3px; 1884 } 1885 </style> 1886 ``` 1887 1888 ### TodoistSettings Component 1889 1890 Settings modal for API token configuration: 1891 1892 ```svelte 1893 <!-- src/components/todoist/TodoistSettings.svelte --> 1894 <script lang="ts"> 1895 import { createEventDispatcher, onMount } from 'svelte' 1896 import { todoistClient } from '../../lib/todoist/client' 1897 import { workspaceSyncService } from '../../lib/todoist/workspace-sync' 1898 import type { TodoistConnectionState } from '../../lib/todoist/types' 1899 import { settingsStore } from '../../lib/storage/settings' 1900 1901 const dispatch = createEventDispatcher<{ close: void }>() 1902 1903 let apiToken = '' 1904 let connection: TodoistConnectionState | null = null 1905 let testing = false 1906 let saving = false 1907 let error: string | null = null 1908 1909 onMount(async () => { 1910 // Load existing token from settings 1911 const settings = await settingsStore.get() 1912 if (settings?.todoistApiToken) { 1913 apiToken = settings.todoistApiToken 1914 // Test existing connection 1915 await testConnection() 1916 } 1917 }) 1918 1919 async function testConnection(): Promise<void> { 1920 if (!apiToken.trim()) { 1921 error = 'Please enter an API token' 1922 return 1923 } 1924 1925 testing = true 1926 error = null 1927 1928 try { 1929 connection = await todoistClient.initialize(apiToken) 1930 1931 if (!connection.connected) { 1932 error = connection.error || 'Connection failed' 1933 } 1934 } catch (e) { 1935 error = e instanceof Error ? e.message : 'Connection failed' 1936 } finally { 1937 testing = false 1938 } 1939 } 1940 1941 async function handleSave(): Promise<void> { 1942 if (!connection?.connected) { 1943 await testConnection() 1944 if (!connection?.connected) return 1945 } 1946 1947 saving = true 1948 1949 try { 1950 // Save token to settings 1951 await settingsStore.update({ todoistApiToken: apiToken }) 1952 1953 // Load workspace mappings 1954 await workspaceSyncService.loadMappings() 1955 1956 dispatch('close') 1957 } catch (e) { 1958 error = e instanceof Error ? e.message : 'Failed to save settings' 1959 } finally { 1960 saving = false 1961 } 1962 } 1963 1964 async function handleDisconnect(): Promise<void> { 1965 await settingsStore.update({ todoistApiToken: undefined }) 1966 apiToken = '' 1967 connection = null 1968 dispatch('close') 1969 } 1970 </script> 1971 1972 <div class="modal-overlay" on:click={() => dispatch('close')}> 1973 <div class="modal" on:click|stopPropagation> 1974 <header class="modal-header"> 1975 <h2>Todoist Settings</h2> 1976 <button class="btn-close" on:click={() => dispatch('close')}>×</button> 1977 </header> 1978 1979 <div class="modal-body"> 1980 <div class="field"> 1981 <label for="api-token">API Token</label> 1982 <input 1983 id="api-token" 1984 type="password" 1985 bind:value={apiToken} 1986 placeholder="Enter your Todoist API token" 1987 /> 1988 <p class="hint"> 1989 Find your token at 1990 <a href="https://todoist.com/app/settings/integrations/developer" target="_blank"> 1991 Todoist Settings → Integrations → Developer 1992 </a> 1993 </p> 1994 </div> 1995 1996 {#if error} 1997 <div class="error-message"> 1998 {error} 1999 </div> 2000 {/if} 2001 2002 {#if connection?.connected} 2003 <div class="connection-status success"> 2004 <span class="status-icon">✓</span> 2005 <div> 2006 <strong>Connected</strong> 2007 <p>Master project: {connection.masterProjectName}</p> 2008 {#if connection.lastSync} 2009 <p class="sync-time">Last sync: {new Date(connection.lastSync).toLocaleString()}</p> 2010 {/if} 2011 </div> 2012 </div> 2013 {/if} 2014 2015 <div class="info-box"> 2016 <h4>How it works</h4> 2017 <ul> 2018 <li>A "Mnemonic" project will be created in your Todoist</li> 2019 <li>Each workspace gets a sub-project under Mnemonic</li> 2020 <li>Child workspaces become sub-projects of their parent</li> 2021 <li>Tasks sync bidirectionally with Todoist</li> 2022 </ul> 2023 </div> 2024 </div> 2025 2026 <footer class="modal-footer"> 2027 {#if connection?.connected} 2028 <button class="btn-danger" on:click={handleDisconnect}> 2029 Disconnect 2030 </button> 2031 {/if} 2032 2033 <div class="footer-right"> 2034 <button class="btn-secondary" on:click={testConnection} disabled={testing}> 2035 {testing ? 'Testing...' : 'Test Connection'} 2036 </button> 2037 <button 2038 class="btn-primary" 2039 on:click={handleSave} 2040 disabled={saving || testing} 2041 > 2042 {saving ? 'Saving...' : 'Save'} 2043 </button> 2044 </div> 2045 </footer> 2046 </div> 2047 </div> 2048 2049 <style> 2050 .modal-overlay { 2051 position: fixed; 2052 inset: 0; 2053 background: rgba(0, 0, 0, 0.5); 2054 display: flex; 2055 align-items: center; 2056 justify-content: center; 2057 z-index: 1000; 2058 } 2059 2060 .modal { 2061 background: var(--bg-primary); 2062 border-radius: 8px; 2063 width: 90%; 2064 max-width: 500px; 2065 max-height: 90vh; 2066 overflow: hidden; 2067 display: flex; 2068 flex-direction: column; 2069 } 2070 2071 .modal-header { 2072 display: flex; 2073 align-items: center; 2074 justify-content: space-between; 2075 padding: 1rem; 2076 border-bottom: 1px solid var(--border-color); 2077 } 2078 2079 .modal-header h2 { 2080 margin: 0; 2081 font-size: 1.1rem; 2082 } 2083 2084 .btn-close { 2085 width: 32px; 2086 height: 32px; 2087 border: none; 2088 background: transparent; 2089 font-size: 1.5rem; 2090 cursor: pointer; 2091 border-radius: 4px; 2092 color: var(--text-secondary); 2093 } 2094 2095 .btn-close:hover { 2096 background: var(--bg-hover); 2097 } 2098 2099 .modal-body { 2100 padding: 1rem; 2101 overflow-y: auto; 2102 } 2103 2104 .field { 2105 margin-bottom: 1rem; 2106 } 2107 2108 .field label { 2109 display: block; 2110 font-weight: 500; 2111 margin-bottom: 0.5rem; 2112 } 2113 2114 .field input { 2115 width: 100%; 2116 padding: 0.5rem; 2117 border: 1px solid var(--border-color); 2118 border-radius: 4px; 2119 font-size: 0.9rem; 2120 } 2121 2122 .hint { 2123 margin-top: 0.5rem; 2124 font-size: 0.8rem; 2125 color: var(--text-secondary); 2126 } 2127 2128 .hint a { 2129 color: var(--accent-color); 2130 } 2131 2132 .error-message { 2133 background: rgba(209, 69, 59, 0.1); 2134 color: #d1453b; 2135 padding: 0.75rem; 2136 border-radius: 4px; 2137 margin-bottom: 1rem; 2138 } 2139 2140 .connection-status { 2141 display: flex; 2142 align-items: flex-start; 2143 gap: 0.75rem; 2144 padding: 0.75rem; 2145 border-radius: 4px; 2146 margin-bottom: 1rem; 2147 } 2148 2149 .connection-status.success { 2150 background: rgba(0, 128, 0, 0.1); 2151 color: green; 2152 } 2153 2154 .status-icon { 2155 font-size: 1.25rem; 2156 } 2157 2158 .connection-status strong { 2159 display: block; 2160 } 2161 2162 .connection-status p { 2163 margin: 0.25rem 0 0 0; 2164 font-size: 0.85rem; 2165 } 2166 2167 .sync-time { 2168 color: var(--text-secondary); 2169 } 2170 2171 .info-box { 2172 background: var(--bg-secondary); 2173 padding: 1rem; 2174 border-radius: 4px; 2175 } 2176 2177 .info-box h4 { 2178 margin: 0 0 0.5rem 0; 2179 font-size: 0.9rem; 2180 } 2181 2182 .info-box ul { 2183 margin: 0; 2184 padding-left: 1.25rem; 2185 font-size: 0.85rem; 2186 color: var(--text-secondary); 2187 } 2188 2189 .info-box li { 2190 margin: 0.25rem 0; 2191 } 2192 2193 .modal-footer { 2194 display: flex; 2195 align-items: center; 2196 justify-content: space-between; 2197 padding: 1rem; 2198 border-top: 1px solid var(--border-color); 2199 background: var(--bg-secondary); 2200 } 2201 2202 .footer-right { 2203 display: flex; 2204 gap: 0.5rem; 2205 } 2206 2207 .btn-primary { 2208 padding: 0.5rem 1rem; 2209 background: var(--accent-color); 2210 color: white; 2211 border: none; 2212 border-radius: 4px; 2213 cursor: pointer; 2214 } 2215 2216 .btn-secondary { 2217 padding: 0.5rem 1rem; 2218 background: var(--bg-primary); 2219 border: 1px solid var(--border-color); 2220 border-radius: 4px; 2221 cursor: pointer; 2222 } 2223 2224 .btn-danger { 2225 padding: 0.5rem 1rem; 2226 background: #d1453b; 2227 color: white; 2228 border: none; 2229 border-radius: 4px; 2230 cursor: pointer; 2231 } 2232 2233 button:disabled { 2234 opacity: 0.5; 2235 cursor: not-allowed; 2236 } 2237 </style> 2238 ``` 2239 2240 --- 2241 2242 ## Manifest Permissions 2243 2244 Add to `wxt.config.ts`: 2245 2246 ```typescript 2247 manifest: { 2248 // ... existing config ... 2249 2250 host_permissions: [ 2251 'https://api.todoist.com/*', // Todoist REST API 2252 ], 2253 } 2254 ``` 2255 2256 --- 2257 2258 ## Authentication Considerations 2259 2260 ### Personal API Token (Recommended for Personal Use) 2261 2262 The simplest approach for a personal browser extension: 2263 2264 1. User obtains API token from Todoist Settings → Integrations → Developer 2265 2. User enters token in extension settings 2266 3. Token stored encrypted in browser.storage.local 2267 4. All API calls use Bearer token authentication 2268 2269 ### OAuth2 (For Published Extensions) 2270 2271 If distributing to other users, implement proper OAuth2: 2272 2273 ```typescript 2274 // OAuth2 flow for browser extensions 2275 const CLIENT_ID = 'your-client-id' 2276 const REDIRECT_URI = browser.identity.getRedirectURL() 2277 2278 async function initiateOAuth(): Promise<string> { 2279 const authUrl = new URL('https://todoist.com/oauth/authorize') 2280 authUrl.searchParams.set('client_id', CLIENT_ID) 2281 authUrl.searchParams.set('scope', 'data:read_write') 2282 authUrl.searchParams.set('redirect_uri', REDIRECT_URI) 2283 authUrl.searchParams.set('state', crypto.randomUUID()) 2284 2285 const responseUrl = await browser.identity.launchWebAuthFlow({ 2286 url: authUrl.toString(), 2287 interactive: true, 2288 }) 2289 2290 const url = new URL(responseUrl) 2291 const code = url.searchParams.get('code') 2292 2293 // Exchange code for token (requires backend server) 2294 // Token exchange must happen server-side to protect client_secret 2295 } 2296 ``` 2297 2298 --- 2299 2300 ## Error Handling & Offline Support 2301 2302 ```typescript 2303 // src/lib/todoist/error-handler.ts 2304 2305 export class TodoistError extends Error { 2306 constructor( 2307 message: string, 2308 public code: TodoistErrorCode, 2309 public retryable: boolean = false 2310 ) { 2311 super(message) 2312 this.name = 'TodoistError' 2313 } 2314 } 2315 2316 export enum TodoistErrorCode { 2317 UNAUTHORIZED = 'UNAUTHORIZED', 2318 RATE_LIMITED = 'RATE_LIMITED', 2319 NOT_FOUND = 'NOT_FOUND', 2320 NETWORK_ERROR = 'NETWORK_ERROR', 2321 OFFLINE = 'OFFLINE', 2322 UNKNOWN = 'UNKNOWN', 2323 } 2324 2325 export function handleApiError(error: unknown): TodoistError { 2326 if (error instanceof TypeError && error.message.includes('fetch')) { 2327 return new TodoistError( 2328 'Network error - please check your connection', 2329 TodoistErrorCode.NETWORK_ERROR, 2330 true 2331 ) 2332 } 2333 2334 if (error instanceof Response) { 2335 switch (error.status) { 2336 case 401: 2337 case 403: 2338 return new TodoistError( 2339 'Authentication failed - please check your API token', 2340 TodoistErrorCode.UNAUTHORIZED 2341 ) 2342 case 429: 2343 return new TodoistError( 2344 'Rate limited - please try again in a moment', 2345 TodoistErrorCode.RATE_LIMITED, 2346 true 2347 ) 2348 case 404: 2349 return new TodoistError( 2350 'Resource not found', 2351 TodoistErrorCode.NOT_FOUND 2352 ) 2353 } 2354 } 2355 2356 return new TodoistError( 2357 'An unexpected error occurred', 2358 TodoistErrorCode.UNKNOWN 2359 ) 2360 } 2361 ``` 2362 2363 --- 2364 2365 ## Phase Integration 2366 2367 | Dependency | Reason | 2368 |------------|--------| 2369 | Phase 2 (Dashboard Tab) | UI integration point for TodoistCard | 2370 | Phase 3 (Workspace Management) | Workspace creation/deletion triggers project sync | 2371 | Phase 4 (Context Switching) | Optional: Load tasks on context switch | 2372 | Phase 7 (Settings/Options) | Settings UI for API token | 2373 2374 **Estimated effort:** 2-3 weeks 2375 2376 --- 2377 2378 ## Summary 2379 2380 | Component | Lines | Purpose | 2381 |-----------|-------|---------| 2382 | types.ts | ~80 | Type definitions | 2383 | client.ts | ~200 | API wrapper with browser compatibility | 2384 | workspace-sync.ts | ~150 | Workspace ↔ Project mapping | 2385 | task-store.ts | ~200 | Reactive Svelte store | 2386 | TodoistCard.svelte | ~150 | Main dashboard card | 2387 | TaskItem.svelte | ~180 | Individual task display | 2388 | TaskEditor.svelte | ~160 | Create/edit form | 2389 | TodoistSettings.svelte | ~200 | Settings modal | 2390 | **Total** | **~1,320** | | 2391 2392 --- 2393 2394 *Addendum Version: 1.0 | December 2024*