Files
Claude 7dd2166bf4 Implement Phase 1: Daily Helper Foundation
Complete implementation of Phase 1 enhancements including:

Backend:
- UserProfile model for storing user preferences (timezone, trading style, risk tolerance)
- DailyRoutine model for scheduling routines (morning, active_trading, evening)
- RoutineExecution model for tracking routine execution history
- Notification model for managing all types of notifications
- DailyChecklist model for daily task tracking with completion percentage
- HabitTracker model for tracking habits and streaks

Services:
- RoutineService: Handles routine execution with task registry pattern
- RoutineScheduler: Async scheduler for automated routine execution
- NotificationService: Comprehensive notification creation and delivery system
- Support for price alerts, news, routines, reminders, and performance notifications

API Endpoints (daily_helper router):
- User profile: CRUD operations, get/update preferences
- Daily routines: Create, list, execute, track history
- Notifications: CRUD, mark read, batch operations
- Daily checklists: CRUD, item management, completion tracking
- Habits: Create, track, log completions, manage streaks
- Dashboard: Summary endpoint for daily helper overview

Frontend Components:
- UserProfileSetup: Complete user profile configuration with preferences
- NotificationCenter: Bell icon with dropdown, notification management
- HabitTracker: Habit creation, streak tracking, gamification with fire emojis
- DailyChecklistPanel: Checklist management with completion percentage

Schemas:
- Full Pydantic schemas for request/response validation
- Type-safe API contracts

Features:
- Timezone support for international users
- Trading style and risk tolerance preferences
- Automated routine execution with task registry
- Real-time notifications with priority levels
- Habit streaks with motivational badges
- Daily checklist with persistent state
- Completion percentage tracking
- Notes and metadata support

All components are production-ready with error handling and user feedback.
2025-11-15 23:09:10 +00:00

647 lines
21 KiB
Python

"""
Daily Helper API endpoints for Phase 1 enhancements
Includes user profiles, routines, notifications, and habit tracking
"""
from fastapi import APIRouter, HTTPException, Depends, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, date, timedelta
from app.db.database import get_db
from app.models.models import (
UserProfile, DailyRoutine, RoutineExecution, Notification,
DailyChecklist, HabitTracker
)
from app.schemas.schemas import (
UserProfileCreate, UserProfileUpdate, UserProfileResponse,
DailyRoutineCreate, DailyRoutineUpdate, DailyRoutineResponse,
RoutineExecutionResponse, NotificationCreate, NotificationResponse,
NotificationListResponse, DailyChecklistCreate, DailyChecklistUpdate,
DailyChecklistResponse, HabitTrackerCreate, HabitTrackerResponse,
HabitCompletionRequest, ChecklistItem
)
router = APIRouter(prefix="/api/daily-helper", tags=["Daily Helper"])
# ============================================================================
# USER PROFILE ENDPOINTS
# ============================================================================
@router.post("/profile", response_model=UserProfileResponse, status_code=status.HTTP_201_CREATED)
async def create_user_profile(
profile: UserProfileCreate,
db: Session = Depends(get_db)
):
"""Create a new user profile"""
# Check if email already exists
if profile.email:
existing = db.query(UserProfile).filter(UserProfile.email == profile.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
db_profile = UserProfile(**profile.dict())
db.add(db_profile)
db.commit()
db.refresh(db_profile)
return db_profile
@router.get("/profile", response_model=UserProfileResponse)
async def get_user_profile(db: Session = Depends(get_db)):
"""Get user profile (current implementation returns first profile)"""
profile = db.query(UserProfile).first()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User profile not found"
)
return profile
@router.put("/profile", response_model=UserProfileResponse)
async def update_user_profile(
profile_update: UserProfileUpdate,
db: Session = Depends(get_db)
):
"""Update user profile"""
profile = db.query(UserProfile).first()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User profile not found"
)
update_data = profile_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(profile, key, value)
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
return profile
@router.delete("/profile", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_profile(db: Session = Depends(get_db)):
"""Delete user profile"""
profile = db.query(UserProfile).first()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User profile not found"
)
db.delete(profile)
db.commit()
# ============================================================================
# DAILY ROUTINE ENDPOINTS
# ============================================================================
@router.post("/routines", response_model=DailyRoutineResponse, status_code=status.HTTP_201_CREATED)
async def create_routine(
routine: DailyRoutineCreate,
db: Session = Depends(get_db)
):
"""Create a new daily routine"""
db_routine = DailyRoutine(**routine.dict())
db.add(db_routine)
db.commit()
db.refresh(db_routine)
return db_routine
@router.get("/routines", response_model=List[DailyRoutineResponse])
async def list_routines(
routine_type: Optional[str] = Query(None),
enabled: Optional[bool] = Query(None),
db: Session = Depends(get_db)
):
"""List daily routines with optional filters"""
query = db.query(DailyRoutine)
if routine_type:
query = query.filter(DailyRoutine.routine_type == routine_type)
if enabled is not None:
query = query.filter(DailyRoutine.enabled == enabled)
return query.all()
@router.get("/routines/{routine_id}", response_model=DailyRoutineResponse)
async def get_routine(routine_id: int, db: Session = Depends(get_db)):
"""Get a specific routine"""
routine = db.query(DailyRoutine).filter(DailyRoutine.id == routine_id).first()
if not routine:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Routine not found"
)
return routine
@router.put("/routines/{routine_id}", response_model=DailyRoutineResponse)
async def update_routine(
routine_id: int,
routine_update: DailyRoutineUpdate,
db: Session = Depends(get_db)
):
"""Update a routine"""
routine = db.query(DailyRoutine).filter(DailyRoutine.id == routine_id).first()
if not routine:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Routine not found"
)
update_data = routine_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(routine, key, value)
routine.updated_at = datetime.utcnow()
db.commit()
db.refresh(routine)
return routine
@router.delete("/routines/{routine_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_routine(routine_id: int, db: Session = Depends(get_db)):
"""Delete a routine"""
routine = db.query(DailyRoutine).filter(DailyRoutine.id == routine_id).first()
if not routine:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Routine not found"
)
db.delete(routine)
db.commit()
@router.post("/routines/{routine_id}/execute", response_model=RoutineExecutionResponse, status_code=status.HTTP_201_CREATED)
async def execute_routine(
routine_id: int,
db: Session = Depends(get_db)
):
"""Execute a routine (log execution)"""
routine = db.query(DailyRoutine).filter(DailyRoutine.id == routine_id).first()
if not routine:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Routine not found"
)
# Create execution record
execution = RoutineExecution(
routine_id=routine_id,
completion_status="completed",
tasks_completed=routine.tasks
)
db.add(execution)
db.commit()
db.refresh(execution)
return execution
@router.get("/routines/{routine_id}/executions", response_model=List[RoutineExecutionResponse])
async def get_routine_executions(
routine_id: int,
limit: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
"""Get execution history for a routine"""
executions = db.query(RoutineExecution).filter(
RoutineExecution.routine_id == routine_id
).order_by(RoutineExecution.executed_at.desc()).limit(limit).all()
return executions
# ============================================================================
# NOTIFICATION ENDPOINTS
# ============================================================================
@router.post("/notifications", response_model=NotificationResponse, status_code=status.HTTP_201_CREATED)
async def create_notification(
notification: NotificationCreate,
db: Session = Depends(get_db)
):
"""Create a new notification"""
db_notification = Notification(**notification.dict())
db.add(db_notification)
db.commit()
db.refresh(db_notification)
return db_notification
@router.get("/notifications", response_model=NotificationListResponse)
async def get_notifications(
notification_type: Optional[str] = Query(None),
read: Optional[bool] = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db)
):
"""Get notifications with optional filters"""
query = db.query(Notification)
if notification_type:
query = query.filter(Notification.notification_type == notification_type)
if read is not None:
query = query.filter(Notification.read == read)
total = query.count()
unread = query.filter(Notification.read == False).count()
notifications = query.order_by(
Notification.created_at.desc()
).limit(limit).offset(offset).all()
return NotificationListResponse(
notifications=notifications,
unread_count=unread,
total_count=total
)
@router.get("/notifications/{notification_id}", response_model=NotificationResponse)
async def get_notification(notification_id: int, db: Session = Depends(get_db)):
"""Get a specific notification"""
notification = db.query(Notification).filter(
Notification.id == notification_id
).first()
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
return notification
@router.put("/notifications/{notification_id}/read", response_model=NotificationResponse)
async def mark_notification_read(
notification_id: int,
db: Session = Depends(get_db)
):
"""Mark notification as read"""
notification = db.query(Notification).filter(
Notification.id == notification_id
).first()
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
notification.read = True
notification.read_at = datetime.utcnow()
db.commit()
db.refresh(notification)
return notification
@router.post("/notifications/mark-all-read")
async def mark_all_notifications_read(db: Session = Depends(get_db)):
"""Mark all notifications as read"""
db.query(Notification).filter(Notification.read == False).update({
Notification.read: True,
Notification.read_at: datetime.utcnow()
})
db.commit()
return {"message": "All notifications marked as read"}
@router.delete("/notifications/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_notification(notification_id: int, db: Session = Depends(get_db)):
"""Delete a notification"""
notification = db.query(Notification).filter(
Notification.id == notification_id
).first()
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
db.delete(notification)
db.commit()
# ============================================================================
# DAILY CHECKLIST ENDPOINTS
# ============================================================================
@router.post("/checklists", response_model=DailyChecklistResponse, status_code=status.HTTP_201_CREATED)
async def create_checklist(
checklist: DailyChecklistCreate,
db: Session = Depends(get_db)
):
"""Create a new daily checklist"""
db_checklist = DailyChecklist(**checklist.dict())
db.add(db_checklist)
db.commit()
db.refresh(db_checklist)
return db_checklist
@router.get("/checklists/today", response_model=Optional[DailyChecklistResponse])
async def get_today_checklist(
checklist_type: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Get today's checklist"""
query = db.query(DailyChecklist).filter(
DailyChecklist.checklist_date == date.today()
)
if checklist_type:
query = query.filter(DailyChecklist.checklist_type == checklist_type)
return query.first()
@router.get("/checklists", response_model=List[DailyChecklistResponse])
async def list_checklists(
checklist_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
limit: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db)
):
"""List checklists with optional filters"""
query = db.query(DailyChecklist)
if checklist_type:
query = query.filter(DailyChecklist.checklist_type == checklist_type)
if start_date:
try:
start = datetime.fromisoformat(start_date).date()
query = query.filter(DailyChecklist.checklist_date >= start)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid start_date format")
if end_date:
try:
end = datetime.fromisoformat(end_date).date()
query = query.filter(DailyChecklist.checklist_date <= end)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid end_date format")
return query.order_by(DailyChecklist.checklist_date.desc()).limit(limit).all()
@router.get("/checklists/{checklist_id}", response_model=DailyChecklistResponse)
async def get_checklist(checklist_id: int, db: Session = Depends(get_db)):
"""Get a specific checklist"""
checklist = db.query(DailyChecklist).filter(
DailyChecklist.id == checklist_id
).first()
if not checklist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Checklist not found"
)
return checklist
@router.put("/checklists/{checklist_id}", response_model=DailyChecklistResponse)
async def update_checklist(
checklist_id: int,
checklist_update: DailyChecklistUpdate,
db: Session = Depends(get_db)
):
"""Update a checklist"""
checklist = db.query(DailyChecklist).filter(
DailyChecklist.id == checklist_id
).first()
if not checklist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Checklist not found"
)
update_data = checklist_update.dict(exclude_unset=True)
# Calculate completion percentage if items are provided
if "items" in update_data:
items = update_data["items"]
if items:
completed = sum(1 for item in items if item.get("completed", False))
completion_pct = (completed / len(items)) * 100
update_data["completion_percentage"] = completion_pct
for key, value in update_data.items():
setattr(checklist, key, value)
checklist.updated_at = datetime.utcnow()
db.commit()
db.refresh(checklist)
return checklist
@router.put("/checklists/{checklist_id}/items/{item_id}", response_model=DailyChecklistResponse)
async def update_checklist_item(
checklist_id: int,
item_id: str,
completed: bool,
db: Session = Depends(get_db)
):
"""Update a specific checklist item"""
checklist = db.query(DailyChecklist).filter(
DailyChecklist.id == checklist_id
).first()
if not checklist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Checklist not found"
)
# Find and update item
items = checklist.items or []
for item in items:
if item.get("id") == item_id:
item["completed"] = completed
if completed:
item["completed_at"] = datetime.utcnow().isoformat()
break
# Recalculate completion percentage
if items:
completed_count = sum(1 for item in items if item.get("completed", False))
checklist.completion_percentage = (completed_count / len(items)) * 100
checklist.items = items
checklist.updated_at = datetime.utcnow()
db.commit()
db.refresh(checklist)
return checklist
@router.delete("/checklists/{checklist_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_checklist(checklist_id: int, db: Session = Depends(get_db)):
"""Delete a checklist"""
checklist = db.query(DailyChecklist).filter(
DailyChecklist.id == checklist_id
).first()
if not checklist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Checklist not found"
)
db.delete(checklist)
db.commit()
# ============================================================================
# HABIT TRACKER ENDPOINTS
# ============================================================================
@router.post("/habits", response_model=HabitTrackerResponse, status_code=status.HTTP_201_CREATED)
async def create_habit(
habit: HabitTrackerCreate,
db: Session = Depends(get_db)
):
"""Create a new habit to track"""
db_habit = HabitTracker(**habit.dict())
db.add(db_habit)
db.commit()
db.refresh(db_habit)
return db_habit
@router.get("/habits", response_model=List[HabitTrackerResponse])
async def list_habits(
habit_name: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""List all habits"""
query = db.query(HabitTracker)
if habit_name:
query = query.filter(HabitTracker.habit_name.ilike(f"%{habit_name}%"))
return query.all()
@router.get("/habits/{habit_id}", response_model=HabitTrackerResponse)
async def get_habit(habit_id: int, db: Session = Depends(get_db)):
"""Get a specific habit"""
habit = db.query(HabitTracker).filter(HabitTracker.id == habit_id).first()
if not habit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Habit not found"
)
return habit
@router.post("/habits/{habit_id}/log", response_model=HabitTrackerResponse)
async def log_habit_completion(
habit_id: int,
request: HabitCompletionRequest,
db: Session = Depends(get_db)
):
"""Log habit completion"""
habit = db.query(HabitTracker).filter(HabitTracker.id == habit_id).first()
if not habit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Habit not found"
)
completion_date = request.completion_date or date.today().isoformat()
completion_dates = habit.completion_dates or []
# Avoid duplicates
if completion_date not in completion_dates:
completion_dates.append(completion_date)
habit.completion_dates = completion_dates
habit.total_completions = len(completion_dates)
# Calculate streaks
sorted_dates = sorted(completion_dates)
current_streak = 0
for i in range(len(sorted_dates) - 1, -1, -1):
current_date = datetime.fromisoformat(sorted_dates[i]).date()
expected_date = date.today() - timedelta(days=len(sorted_dates) - 1 - i)
if i == len(sorted_dates) - 1:
if current_date >= date.today() - timedelta(days=1):
current_streak = 1
else:
break
else:
next_date = datetime.fromisoformat(sorted_dates[i + 1]).date()
if next_date - current_date == timedelta(days=1):
current_streak += 1
else:
break
habit.current_streak = current_streak
habit.longest_streak = max(habit.longest_streak or 0, current_streak)
habit.updated_at = datetime.utcnow()
db.commit()
db.refresh(habit)
return habit
@router.delete("/habits/{habit_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_habit(habit_id: int, db: Session = Depends(get_db)):
"""Delete a habit"""
habit = db.query(HabitTracker).filter(HabitTracker.id == habit_id).first()
if not habit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Habit not found"
)
db.delete(habit)
db.commit()
# ============================================================================
# SUMMARY ENDPOINTS
# ============================================================================
@router.get("/dashboard")
async def get_dashboard_summary(db: Session = Depends(get_db)):
"""Get daily helper dashboard summary"""
today = date.today()
# Get today's checklists
checklists = db.query(DailyChecklist).filter(
DailyChecklist.checklist_date == today
).all()
# Get unread notifications
unread_notifications = db.query(Notification).filter(
Notification.read == False
).count()
# Get habits
habits = db.query(HabitTracker).all()
habit_summary = {
"total": len(habits),
"completed_today": sum(
1 for h in habits
if h.completion_dates and today.isoformat() in h.completion_dates
)
}
# Get user profile
profile = db.query(UserProfile).first()
return {
"user_profile": profile if profile else None,
"today_date": today.isoformat(),
"checklists": checklists,
"checklists_count": len(checklists),
"unread_notifications": unread_notifications,
"habits_summary": habit_summary,
"pending_routines": db.query(DailyRoutine).filter(
DailyRoutine.enabled == True
).count()
}