Add staged tasks CRUD + daily scores endpoints (Day 2)#5375
Add staged tasks CRUD + daily scores endpoints (Day 2)#5375beastoin merged 11 commits intocollab/5302-integrationfrom
Conversation
Greptile SummaryThis PR ports the desktop staged tasks CRUD and daily/weekly/overall score endpoints from a Rust backend to Python/FastAPI, adding 7 new endpoints and a clean database abstraction layer in
Confidence Score: 2/5
Sequence DiagramsequenceDiagram
participant Client
participant Router as routers/staged_tasks.py
participant DB as database/staged_tasks.py
participant Firestore
Note over Client,Firestore: POST /v1/staged-tasks/promote
Client->>Router: POST /v1/staged-tasks/promote
Router->>DB: get_active_ai_action_items(uid)
DB->>Firestore: query action_items where from_staged=true, completed=false
Firestore-->>DB: active items
DB-->>Router: active_items list
alt len(active_items) >= 5
Router-->>Client: {promoted: false, reason: "max 5"}
else
Router->>DB: get_staged_tasks(uid, limit=20)
DB->>Firestore: query staged_tasks ORDER BY relevance_score ASC LIMIT 21
Firestore-->>DB: staged items
DB-->>Router: staged_items
loop for each staged task
Router->>Router: normalize description, check against existing_descriptions + seen_descriptions
alt duplicate
Router->>Router: add to duplicate_ids
else first non-duplicate
Router->>Router: set as selected_task
end
end
opt duplicate_ids not empty
Router->>DB: delete_staged_tasks_batch(uid, duplicate_ids)
DB->>Firestore: batch.delete() for each duplicate
end
alt selected_task is None
Router-->>Client: {promoted: false, reason: "all duplicates"}
else
Router->>DB: promote_staged_task(uid, selected_task)
DB->>Firestore: action_items.add(action_item_data with from_staged=true)
Firestore-->>DB: new action item doc ref
DB-->>Router: action_item with id
Router->>DB: delete_staged_task(uid, selected_task.id)
DB->>Firestore: staged_tasks/{id}.delete()
Router-->>Client: {promoted: true, promoted_task: {...}}
end
end
Last reviewed commit: 67e6f59 |
backend/routers/staged_tasks.py
Outdated
| from datetime import date as date_type | ||
|
|
||
| if date: | ||
| try: | ||
| parsed = datetime.strptime(date, '%Y-%m-%d').date() | ||
| except ValueError: | ||
| raise HTTPException(status_code=400, detail='Invalid date format, use YYYY-MM-DD') | ||
| else: | ||
| parsed = datetime.now().date() | ||
|
|
||
| date_str = parsed.strftime('%Y-%m-%d') | ||
| due_start = f'{date_str}T00:00:00Z' | ||
| due_end = f'{date_str}T23:59:59.999Z' | ||
|
|
||
| completed, total = staged_tasks_db.get_action_items_for_daily_score(uid, due_start, due_end) | ||
| score = (completed / total * 100.0) if total > 0 else 0.0 | ||
|
|
||
| return DailyScoreResponse(score=score, completed_tasks=completed, total_tasks=total, date=date_str) | ||
|
|
||
|
|
||
| @router.get('/v1/scores', response_model=ScoresResponse, tags=['scores']) | ||
| def get_scores( | ||
| date: Optional[str] = Query(default=None, description='Date in YYYY-MM-DD format'), | ||
| uid: str = Depends(auth.get_current_user_uid), | ||
| ): | ||
| """Get daily, weekly, and overall scores with default tab selection.""" | ||
| from datetime import timedelta |
There was a problem hiding this comment.
In-function imports violate project rules. The date as date_type import on line 229 is also never used (dead code).
Both timedelta (line 255) and date_type (line 229) should be moved to the top-level imports alongside the existing from datetime import datetime on line 13. The unused date_type alias should be removed entirely.
| from datetime import date as date_type | |
| if date: | |
| try: | |
| parsed = datetime.strptime(date, '%Y-%m-%d').date() | |
| except ValueError: | |
| raise HTTPException(status_code=400, detail='Invalid date format, use YYYY-MM-DD') | |
| else: | |
| parsed = datetime.now().date() | |
| date_str = parsed.strftime('%Y-%m-%d') | |
| due_start = f'{date_str}T00:00:00Z' | |
| due_end = f'{date_str}T23:59:59.999Z' | |
| completed, total = staged_tasks_db.get_action_items_for_daily_score(uid, due_start, due_end) | |
| score = (completed / total * 100.0) if total > 0 else 0.0 | |
| return DailyScoreResponse(score=score, completed_tasks=completed, total_tasks=total, date=date_str) | |
| @router.get('/v1/scores', response_model=ScoresResponse, tags=['scores']) | |
| def get_scores( | |
| date: Optional[str] = Query(default=None, description='Date in YYYY-MM-DD format'), | |
| uid: str = Depends(auth.get_current_user_uid), | |
| ): | |
| """Get daily, weekly, and overall scores with default tab selection.""" | |
| from datetime import timedelta | |
| from datetime import datetime, timedelta, timezone |
Then remove lines 229 and 255.
Context Used: Rule from dashboard - Backend Python import rules - no in-function imports, follow module hierarchy (source)
| except ValueError: | ||
| raise HTTPException(status_code=400, detail='Invalid date format, use YYYY-MM-DD') | ||
| else: | ||
| parsed = datetime.now().date() |
There was a problem hiding this comment.
Timezone-naive datetime.now().date() will use the server's local timezone instead of UTC. Since the boundary strings due_start/due_end are formatted as UTC (with Z suffix) and compared against Firestore timestamps stored in UTC, the date can be off by a day depending on the server's timezone offset.
Example: Server in EST (UTC-5), local time 11 PM Jan 5 = 4 AM UTC Jan 6. datetime.now().date() returns Jan 5, but the UTC date is actually Jan 6, so the query gets the wrong day's items.
| parsed = datetime.now().date() | |
| parsed = datetime.now(timezone.utc).date() |
| except ValueError: | ||
| raise HTTPException(status_code=400, detail='Invalid date format, use YYYY-MM-DD') | ||
| else: | ||
| parsed = datetime.now().date() |
There was a problem hiding this comment.
Same timezone-naive issue as line 237: datetime.now().date() uses server local timezone instead of UTC, causing date boundary mismatches with UTC-formatted Firestore timestamps.
| parsed = datetime.now().date() | |
| parsed = datetime.now(timezone.utc).date() |
| batch.update(doc_ref, {'relevance_score': item['relevance_score'], 'updated_at': now}) | ||
| batch.commit() |
There was a problem hiding this comment.
Firestore's batch.update() raises google.cloud.exceptions.NotFound at commit time if any document in the batch does not exist. There is no error handling in the router or here (line 132 in routers/staged_tasks.py), so a single invalid task ID in the request will produce an unhandled 500 error instead of a 400/404.
Use batch.set(..., merge=True) to upsert instead, or wrap batch.commit() in a try/except that translates NotFound to a 400/404 response.
| batch.update(doc_ref, {'relevance_score': item['relevance_score'], 'updated_at': now}) | |
| batch.commit() | |
| batch.set(doc_ref, {'relevance_score': item['relevance_score'], 'updated_at': now}, merge=True) | |
| batch.commit() |
…y uses created_at, idempotent delete
…boundary caps (50 total)
Sub-PR Complete — All Checkpoints Passed
Test evidenceCoverage summary
Ready for merge into by AI for @beastoin |
Summary
Port staged tasks (7 endpoints) and daily scores (2 endpoints) from Rust desktop backend to Python, as part of Day 2 migration (#5302).
Endpoints added
POST /v1/staged-tasks— create with case-insensitive dedupGET /v1/staged-tasks— list filtered by completed=false, ordered by relevance_score ASC + created_at DESC tie-breakDELETE /v1/staged-tasks/{id}— idempotent hard-delete (matches Rust)PATCH /v1/staged-tasks/batch-scores— batch update relevance scoresPOST /v1/staged-tasks/promote— promote top-ranked task to action_items (max 5 active AI tasks, [screen] prefix/suffix dedup)GET /v1/daily-score— daily completion score (due_at filter)GET /v1/scores— daily + weekly + overall scores with default_tab selectionKey behaviors matching Rust
completed=falseFirestore filter, client-side deleted skip, created_at DESC tie-breakcreated_atrange (notdue_at)[screen]prefix/suffix for dedup comparisonReview cycle changes
Test coverage (50 tests)
All 50 tests pass. Tests are registered in
test.sh.Test plan
pytest tests/unit/test_staged_tasks.py -v)Closes part of #5302
🤖 Generated with Claude Code