# Design Document: Call List Feature ## Overview The Call List feature adds a dedicated phone-call tracking system to OrgMyLife. It introduces a new `CallItem` model, a CRUD REST API at `/api/calls`, a phone number extraction service for German formats, a new "📞 Calls" frontend tab, and integration with the existing Daily Digest. The design follows existing OrgMyLife patterns: SQLAlchemy model in `app/models.py`, FastAPI routes in `app/main.py`, service logic in `app/services/`, and vanilla JS frontend in `frontend/js/app.js`. ## Architecture ```mermaid graph TD subgraph Frontend A[Task List View] -->|📞 button click| B[POST /api/calls] C[Calls Tab] -->|load| D[GET /api/calls] C -->|mark done| E[PUT /api/calls/:id] C -->|delete| F[DELETE /api/calls/:id] end subgraph Backend API B --> G[Call List Service] D --> G E --> G F --> G end subgraph Services G --> H[Phone Extractor] G --> I[Task Completion Flow] J[Daily Digest Service] --> G end subgraph Database G --> K[(call_items table)] I --> L[(tasks table)] end ``` ### Request Flow: Create Call from Task 1. User clicks 📞 on a task in the Task List 2. Frontend sends `POST /api/calls` with `{ task_id: }` 3. Backend loads the task, extracts sender name from description 4. Phone Extractor scans the task description for German phone numbers 5. CallItem is created with person, reason, phone_number, original_task_id 6. Response returns the created CallItem ### Request Flow: Complete Call with Task Resolution 1. User clicks "Done" on a call item that has `original_task_id` 2. Frontend sends `PUT /api/calls/:id` with `{ status: "done", resolve_task: true }` 3. Backend marks CallItem as done 4. If `resolve_task` is true, backend marks the original task as "completed" and triggers existing side effects (email archiving, Nextcloud sync) ## Components and Interfaces ### 1. CallItem Model (`app/models.py`) New SQLAlchemy model added to the existing models file: ```python class CallItem(Base): __tablename__ = "call_items" id = Column(Integer, primary_key=True, index=True) person = Column(String(200), nullable=False) reason = Column(String(500), nullable=False) phone_number = Column(String(30), nullable=True) original_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) priority = Column(Integer, default=3) # 1=Very High, 2=High, 3=Medium, 4=Low status = Column(String, default="pending") # "pending" or "done" created_at = Column(DateTime(timezone=True), server_default=func.now()) ``` ### 2. Phone Extractor Service (`app/services/phone_extractor.py`) Pure function module with no external dependencies: ```python def extract_phone_number(text: str) -> str | None: """Extract the first German phone number from text. Supports formats: - +49 xxx xxxx xxxx (international) - 0xxx xxxxxxx (domestic with area code) - (0xxx) xxxxxxx (domestic with parenthesized area code) Returns the first match or None. """ def normalize_phone_number(raw: str) -> str: """Normalize a phone number string by removing separators and standardizing format.""" def parse_phone_number(text: str) -> dict | None: """Parse a phone number string into components: country_code, area_code, subscriber.""" ``` ### 3. Call List API Routes (`app/main.py`) New endpoints following existing patterns (session auth, Pydantic models): | Method | Path | Description | |--------|------|-------------| | POST | `/api/calls` | Create call item (from task or manual) | | GET | `/api/calls` | List pending call items | | PUT | `/api/calls/{id}` | Update call item (status, phone, priority) | | DELETE | `/api/calls/{id}` | Remove call item | ### 4. Pydantic Request/Response Models ```python class CallItemCreate(BaseModel): task_id: Optional[int] = None # Create from existing task person: Optional[str] = None # Required if no task_id reason: Optional[str] = None # Required if no task_id phone_number: Optional[str] = None priority: Optional[int] = 3 class CallItemUpdate(BaseModel): phone_number: Optional[str] = None priority: Optional[int] = None status: Optional[str] = None # "pending" or "done" resolve_task: Optional[bool] = False # Also complete original task? class CallItemResponse(BaseModel): id: int person: str reason: str phone_number: Optional[str] original_task_id: Optional[int] priority: int status: str created_at: str ``` ### 5. Daily Digest Integration Extend `generate_daily_digest()` in `app/services/digest.py` to query pending CallItems and include a `"pending_calls"` section in the response dict. ### 6. Frontend Components - **Navigation**: Add "📞 Calls" button between "Projects" and "Daily Digest" - **Calls View** (`#calls-view`): New section with call item list, inline phone edit - **Task Item**: Add 📞 button to existing task action buttons - **Digest View**: Render the `pending_calls` section when present ## Data Models ### CallItem Table Schema | Column | Type | Constraints | Default | |--------|------|-------------|---------| | id | INTEGER | PRIMARY KEY, INDEX | auto | | person | VARCHAR(200) | NOT NULL | — | | reason | VARCHAR(500) | NOT NULL | — | | phone_number | VARCHAR(30) | NULLABLE | NULL | | original_task_id | INTEGER | FK → tasks.id, NULLABLE | NULL | | priority | INTEGER | — | 3 | | status | VARCHAR | — | "pending" | | created_at | DATETIME(tz) | — | CURRENT_TIMESTAMP | ### Alembic Migration New migration file `005_add_call_items.py`: ```python def upgrade() -> None: op.create_table('call_items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('person', sa.String(200), nullable=False), sa.Column('reason', sa.String(500), nullable=False), sa.Column('phone_number', sa.String(30), nullable=True), sa.Column('original_task_id', sa.Integer(), nullable=True), sa.Column('priority', sa.Integer(), server_default='3'), sa.Column('status', sa.String(), server_default="'pending'"), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')), sa.ForeignKeyConstraint(['original_task_id'], ['tasks.id']), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_call_items_id'), 'call_items', ['id'], unique=False) op.create_index(op.f('ix_call_items_status'), 'call_items', ['status'], unique=False) def downgrade() -> None: op.drop_index(op.f('ix_call_items_status'), table_name='call_items') op.drop_index(op.f('ix_call_items_id'), table_name='call_items') op.drop_table('call_items') ``` ### Relationship to Existing Models ```mermaid erDiagram tasks { int id PK string title text description int priority string status string origin string source_id } call_items { int id PK string person string reason string phone_number int original_task_id FK int priority string status datetime created_at } tasks ||--o{ call_items : "original_task_id" ``` ## Correctness Properties *A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* ### Property 1: Create-from-task field mapping *For any* valid Task with origin "gmail" or "dhive_email" and a description starting with "From: {name}", creating a CallItem from that task SHALL produce a CallItem where: person equals the extracted sender name, reason equals the task title, original_task_id equals the task ID, priority is 3, and status is "pending". **Validates: Requirements 1.1, 1.2, 1.3, 1.5** ### Property 2: Phone number extraction from German formats *For any* text string containing a valid German phone number (in +49, 0xxx, or (0xxx) format), the Phone_Extractor SHALL return a non-null result matching the first phone number occurrence in the text. **Validates: Requirements 4.1, 4.2, 4.3, 4.4** ### Property 3: Phone number parse-format round-trip *For any* valid German phone number string, parsing then formatting then parsing again SHALL produce an equivalent normalized result to parsing once. **Validates: Requirements 4.6** ### Property 4: Call list ordering *For any* set of pending CallItems with varying priorities and creation dates, the GET /api/calls response SHALL return items sorted by priority ascending, then by created_at ascending within the same priority. **Validates: Requirements 2.2** ### Property 5: Field length truncation *For any* string input to person (>200 chars), reason (>500 chars), or phone_number (>30 chars) fields, the stored value SHALL be truncated to the respective maximum length, and the stored value SHALL equal the first N characters of the input. **Validates: Requirements 9.1, 9.2, 9.3** ### Property 6: Priority clamping *For any* integer value provided as priority, the stored priority SHALL be clamped to the range [1, 4] — values below 1 become 1, values above 4 become 4. **Validates: Requirements 9.4** ### Property 7: Status constraint *For any* status value provided to a CallItem update, only "pending" and "done" SHALL be accepted; any other value SHALL be rejected or ignored. **Validates: Requirements 3.3** ### Property 8: Digest includes all pending calls *For any* non-empty set of CallItems with status "pending", the generated daily digest SHALL include a "pending_calls" section containing an entry for each pending item with its person name and reason. **Validates: Requirements 7.1, 7.2** ### Property 9: Call completion without task resolution *For any* CallItem with an original_task_id, marking it as "done" with resolve_task=false SHALL update only the CallItem status to "done" without modifying the Original_Task status. **Validates: Requirements 8.3** ## Error Handling ### API Error Responses | Scenario | HTTP Status | Response Body | |----------|-------------|---------------| | Unauthenticated request | 401 | `{"detail": "Not authenticated"}` | | CallItem not found | 404 | `{"detail": "Call item not found"}` | | Missing required field (person/reason) | 422 | Pydantic validation error | | Invalid status value | 400 | `{"detail": "Status must be 'pending' or 'done'"}` | | Referenced task_id not found | 404 | `{"detail": "Task not found"}` | ### Phone Extractor Error Handling - Empty or None input → returns None (no exception) - Text with no phone numbers → returns None - Malformed numbers (too short/long) → skipped by regex, not returned - Non-string input → TypeError raised (caller responsibility) ### Task Resolution Errors When completing a call with `resolve_task=true`: - If the original task no longer exists → log warning, complete call item only - If email archiving fails → log error, still mark task as completed locally - Follows the same error handling pattern as existing `update_task` endpoint ## Testing Strategy ### Property-Based Tests (using Hypothesis) The project uses Python, so **Hypothesis** is the property-based testing library. Each property test runs a minimum of 100 iterations. Tests will be placed in `OrgMyLife/tests/test_call_list.py` and `OrgMyLife/tests/test_phone_extractor.py`. **Property tests cover:** - Phone number extraction (Properties 2, 3) - Field truncation and priority clamping (Properties 5, 6) - Status constraint validation (Property 7) - Create-from-task field mapping (Property 1) - Call list ordering (Property 4) - Digest integration (Property 8) - Completion without resolution (Property 9) Each test is tagged with: `# Feature: call-list, Property {N}: {description}` Configuration: `@settings(max_examples=100)` ### Unit Tests (example-based) - Authentication enforcement (401 for all endpoints) - 404 for non-existent IDs - DELETE removes item from database - POST without person/reason returns 422 - Phone extractor returns None for text without numbers - Digest omits calls section when no pending items exist - Frontend renders tel: link for items with phone numbers ### Integration Tests - Create call from task → verify task side effects (email archiving) on completion - Full flow: create from task → mark done with resolve_task=true → verify original task completed - Alembic migration up/down cycle ### Test File Structure ``` OrgMyLife/tests/ ├── test_phone_extractor.py # Property tests for extraction + round-trip ├── test_call_list.py # Property + unit tests for API and service logic └── test_call_digest.py # Property tests for digest integration ```