13 KiB
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
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
- User clicks 📞 on a task in the Task List
- Frontend sends
POST /api/callswith{ task_id: <id> } - Backend loads the task, extracts sender name from description
- Phone Extractor scans the task description for German phone numbers
- CallItem is created with person, reason, phone_number, original_task_id
- Response returns the created CallItem
Request Flow: Complete Call with Task Resolution
- User clicks "Done" on a call item that has
original_task_id - Frontend sends
PUT /api/calls/:idwith{ status: "done", resolve_task: true } - Backend marks CallItem as done
- If
resolve_taskis 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:
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:
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
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_callssection 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:
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
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_taskendpoint
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