Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions data/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
from .feedback import Feedback
from .file import FileRecord
from .knowledge import KnowledgeBase, KnowledgeFile
main
from .study_session import StudySession
from .focus_settings import FocusSettings

from .model import ModelConfig
Comment on lines 5 to 10
from .support import Support, SupportFile
from .user import User
main

Comment on lines 10 to 14
__all__ = [
"User",
Expand All @@ -18,4 +23,6 @@
"AppConfig",
"KnowledgeBase",
"KnowledgeFile",
"StudySession",
"FocusSettings",
]
14 changes: 14 additions & 0 deletions data/models/focus_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Pomodoro focus settings — one row per user."""

from sqlalchemy import Column, String, Integer
from data.database import Base


class FocusSettings(Base):
__tablename__ = "focus_settings"

user_id = Column(String(36), primary_key=True)
work_minutes = Column(Integer, nullable=False, default=25)
break_minutes = Column(Integer, nullable=False, default=5)
long_break_minutes = Column(Integer, nullable=False, default=15)
long_break_interval = Column(Integer, nullable=False, default=4)
16 changes: 16 additions & 0 deletions data/models/study_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Study session model — tracks Pomodoro focus sessions."""

from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Boolean
from data.database import Base


class StudySession(Base):
__tablename__ = "study_sessions"

id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
duration_minutes = Column(Integer, nullable=False, default=25)
session_type = Column(String(20), nullable=False, default="work") # work | break
completed = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
2 changes: 2 additions & 0 deletions gateway/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
)
from .routers import (
tasks as tasks_router,
dashboard as dashboard_router,
)
from .routers import (
tools as tools_router,
Expand Down Expand Up @@ -164,6 +165,7 @@ async def reject_legacy_realtime_path(request: Request, call_next):
app.include_router(groups_router.router, prefix="/api/v1")
app.include_router(folders_router.router, prefix="/api/v1")
app.include_router(tasks_router.router, prefix="/api/v1")
app.include_router(dashboard_router.router, prefix="/api/v1")

# Socket.IO — mounted at /realtime; client uses path='/realtime/socket.io'
app.mount("/realtime", socket_app)
Expand Down
123 changes: 123 additions & 0 deletions gateway/http/routers/dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Dashboard & Focus router."""

from datetime import datetime

from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
Comment on lines +3 to +7

from data.database import get_db
from data.models import User
from gateway.http.dependencies import get_current_user
from learning.dashboard.service import DashboardService

router = APIRouter(tags=["dashboard"])


def _svc(db: Session = Depends(get_db)) -> DashboardService:
return DashboardService(db)


# ── Performance ────────────────────────────────────────────────────────────────


@router.get("/dashboard/statistics")
def get_statistics(
year: int = Query(None),
month: int = Query(None),
start_date: str = Query(None),
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
return svc.get_statistics(current_user.id, year, month, start_date)

Comment on lines +24 to +33

@router.get("/dashboard/performance")
def get_performance(
period: str = Query("monthly"),
year: int = Query(None),
month: int = Query(None),
start_date: str = Query(None),
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
return svc.get_performance(current_user.id, period, year, month, start_date)


@router.get("/dashboard/calendar")
def get_calendar(
year: int = Query(None),
month: int = Query(None),
start_date: str = Query(None),
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
now = datetime.utcnow()
return svc.get_calendar(
current_user.id,
year or now.year,
month or now.month,
start_date,
)


@router.get("/dashboard/productivity")
def get_productivity(
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
return svc.get_productivity(current_user.id)


# ── Focus (Pomodoro) ───────────────────────────────────────────────────────────


class FocusSessionRequest(BaseModel):
duration_minutes: int
session_type: str = "work"


class FocusSettingsRequest(BaseModel):
work_minutes: int
break_minutes: int
long_break_minutes: int = 15
long_break_interval: int = 4

Comment on lines +75 to +85

@router.post("/focus/session")
def record_focus_session(
data: FocusSessionRequest,
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
sess = svc.record_session(current_user.id, data.duration_minutes, data.session_type)
return {"id": sess.id, "status": "recorded"}


@router.get("/focus/settings")
def get_focus_settings(
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
s = svc.get_focus_settings(current_user.id)
return {
"work_minutes": s.work_minutes,
"break_minutes": s.break_minutes,
"long_break_minutes": s.long_break_minutes,
"long_break_interval": s.long_break_interval,
}


@router.put("/focus/settings")
def update_focus_settings(
data: FocusSettingsRequest,
current_user: User = Depends(get_current_user),
svc: DashboardService = Depends(_svc),
):
s = svc.update_focus_settings(current_user.id, data.model_dump())
return {
"work_minutes": s.work_minutes,
"break_minutes": s.break_minutes,
"long_break_minutes": s.long_break_minutes,
"long_break_interval": s.long_break_interval,
}
Empty file.
185 changes: 185 additions & 0 deletions learning/dashboard/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Dashboard repository — aggregated queries for stats and calendar."""

from calendar import monthrange
from datetime import datetime, timedelta
from typing import Dict, List, Optional

from sqlalchemy.orm import Session

from data.models.study_session import StudySession
from data.models.support import Support


class DashboardRepository:
def __init__(self, session: Session):
self.session = session

# ── Study sessions ────────────────────────────────────────────────────────

def get_sessions_this_week(self, user_id: str) -> List[StudySession]:
week_ago = datetime.utcnow() - timedelta(days=7)
return (
self.session.query(StudySession)
.filter(
StudySession.user_id == user_id,
StudySession.session_type == "work",
StudySession.completed.is_(True),
StudySession.created_at >= week_ago,
)
.all()
)

def get_sessions_by_day(self, user_id: str) -> List[Dict]:
"""Hours studied per day for the last 7 days (Mon–Sun labels)."""
labels = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
now = datetime.utcnow()
result = []
for i in range(6, -1, -1):
day = now - timedelta(days=i)
start = day.replace(hour=0, minute=0, second=0, microsecond=0)
end = day.replace(hour=23, minute=59, second=59, microsecond=999999)
sessions = (
self.session.query(StudySession)
.filter(
StudySession.user_id == user_id,
StudySession.session_type == "work",
StudySession.completed.is_(True),
StudySession.created_at >= start,
StudySession.created_at <= end,
)
.all()
)
total_minutes = sum(s.duration_minutes for s in sessions)
result.append(
{"day": labels[day.weekday()], "hours": round(total_minutes / 60, 1)}
)
return result

def get_streak(self, user_id: str) -> int:
"""Count consecutive days with at least one completed work session."""
streak = 0
now = datetime.utcnow()
for i in range(365):
day = now - timedelta(days=i)
start = day.replace(hour=0, minute=0, second=0, microsecond=0)
end = day.replace(hour=23, minute=59, second=59, microsecond=999999)
count = (
self.session.query(StudySession)
.filter(
StudySession.user_id == user_id,
StudySession.session_type == "work",
StudySession.completed.is_(True),
StudySession.created_at >= start,
StudySession.created_at <= end,
)
.count()
)
if count > 0:
streak += 1
elif i > 0:
break
return streak
Comment on lines +58 to +81

def get_total_sessions(self, user_id: str) -> int:
return (
self.session.query(StudySession)
.filter(
StudySession.user_id == user_id,
StudySession.session_type == "work",
StudySession.completed.is_(True),
)
.count()
)

# ── Supports → subject stats ──────────────────────────────────────────────

def get_subject_stats(
self,
user_id: str,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> List[Dict]:
q = self.session.query(Support).filter(Support.user_id == user_id)
if start:
q = q.filter(Support.created_at >= start)
if end:
q = q.filter(Support.created_at <= end)
supports = q.all()

subject_map: Dict[str, Dict] = {}
for s in supports:
name = s.subject or s.custom_subject or "Autre"
if name not in subject_map:
subject_map[name] = {"count": 0, "completed": 0}
subject_map[name]["count"] += 1
if s.status == "completed":
subject_map[name]["completed"] += 1

return [
{
"subject": subj,
"total_count": data["count"],
"percentage": (
int((data["completed"] / data["count"]) * 100)
if data["count"]
else 0
),
}
for subj, data in subject_map.items()
]

def get_performance(
self,
user_id: str,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> Dict:
q = self.session.query(Support).filter(Support.user_id == user_id)
if start:
q = q.filter(Support.created_at >= start)
if end:
q = q.filter(Support.created_at <= end)
supports = q.all()

total = len(supports)
completed = sum(1 for s in supports if s.status == "completed")
total_points = total * 5 + completed * 100
return {
"completion_rate": int((completed / total) * 100) if total else 0,
"tutorials_completed": completed,
"total_tutorials": total,
"total_points": total_points,
}

# ── Calendar activities ───────────────────────────────────────────────────

def get_active_days_range(
self, user_id: str, start: datetime, end: datetime
) -> List[int]:
"""Returns list of offsets (0–6) from start date that have activity."""
rows = (
self.session.query(StudySession.created_at)
.filter(
StudySession.user_id == user_id,
StudySession.created_at >= start,
StudySession.created_at <= end,
)
.all()
)
base = start.date()
return sorted({(r.created_at.date() - base).days for r in rows})

def get_active_days(self, user_id: str, year: int, month: int) -> List[int]:
_, last_day = monthrange(year, month)
start = datetime(year, month, 1)
end = datetime(year, month, last_day, 23, 59, 59)
rows = (
self.session.query(StudySession.created_at)
.filter(
StudySession.user_id == user_id,
StudySession.created_at >= start,
StudySession.created_at <= end,
)
.all()
)
return sorted({r.created_at.day for r in rows})
Loading