diff --git a/focus_log/.env.example b/focus_log/.env.example new file mode 100644 index 0000000..0649ded --- /dev/null +++ b/focus_log/.env.example @@ -0,0 +1 @@ +DATABASE_URL="sqlite:///./focus_log.db" diff --git a/focus_log/.gitignore b/focus_log/.gitignore new file mode 100644 index 0000000..f0fdcfe --- /dev/null +++ b/focus_log/.gitignore @@ -0,0 +1,26 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Virtual environment +venv/ +.venv/ +env/ +ENV/ + +# Database files +*.db +*.sqlite3 +focus_log.db +test.db + +# Environment variables +.env + +# Pytest cache +.pytest_cache/ + +# VSCode +.vscode/ diff --git a/focus_log/README.md b/focus_log/README.md new file mode 100644 index 0000000..3a6d39a --- /dev/null +++ b/focus_log/README.md @@ -0,0 +1,135 @@ +# Focus Log API - v2.0 + +Uma API robusta e inteligente para registrar, analisar e gamificar suas sessões de foco, construída com FastAPI e SQLite. Este projeto vai além de um simples CRUD, incorporando uma arquitetura profissional e funcionalidades criativas para se destacar. + +## Filosofia do Projeto + +O objetivo não é apenas armazenar dados, mas transformá-los em **insights acionáveis** e **motivação**. A API foi desenhada para ajudar o usuário a entender seus próprios padrões de produtividade e a construir o hábito da consistência através de mecânicas de gamificação. + +## Funcionalidades Principais + +- **Registro de Foco**: Endpoint para registrar sessões de trabalho ou estudo. +- **Diagnóstico de Produtividade**: Uma análise completa com métricas como média de foco, tempo total e as melhores/piores sessões. +- **Feedback Inteligente**: Mensagens didáticas que explicam *por que* o feedback está sendo dado e sugerem ações. +- **Gamificação (Streaks)**: Sistema de "sequências" que incentiva o registro diário, recompensando a consistência. +- **Análise de Tags**: Correlaciona as tags usadas nos registros com o nível de foco, revelando o que te ajuda ou atrapalha a ser produtivo. + +--- + +## Decisões de Arquitetura e Design + +A estrutura do projeto foi escolhida para garantir **clareza, manutenibilidade e escalabilidade**. + +- **FastAPI**: Escolhido pela sua alta performance, sintaxe moderna com type hints (que reduz bugs) e a geração automática de documentação interativa (Swagger UI), que é excelente para desenvolvimento e testes. + +- **Arquitetura em Camadas**: + - `main.py`: Ponto de entrada. Mínimo de lógica, apenas configurações globais e inclusão dos roteadores. + - `routers/`: Responsáveis por definir os endpoints HTTP. Sua única função é receber requisições, chamar a camada de serviço e retornar respostas. Não contêm lógica de negócio. + - `service.py`: O "cérebro" da aplicação. Contém toda a lógica de negócio (como gerar feedbacks, calcular streaks, analisar tags). Não tem conhecimento sobre HTTP ou o banco de dados diretamente, o que a torna reutilizável e fácil de testar. + - `repository.py`: A única camada que interage com o banco de dados. Abstrai as queries do SQLAlchemy, centralizando o acesso aos dados. + - `models.py` e `schemas.py`: Separação clara entre o modelo do banco de dados (SQLAlchemy) e o modelo da API (Pydantic). Isso permite que a API evolua de forma independente do banco. + +- **SQLite**: Selecionado pela simplicidade. É um banco de dados baseado em arquivo, que não exige instalação ou configuração de um servidor, tornando o projeto extremamente fácil de rodar localmente. Para um ambiente de produção, a troca para um banco como PostgreSQL seria simples, bastando alterar a `DATABASE_URL` e instalar o driver `psycopg2`. + +- **Pydantic para Validação**: Usado extensivamente para validar os dados de entrada (`RegistroFocoCreate`) e garantir a estrutura dos dados de saída (`*Out` schemas). As mensagens de erro customizadas e o handler de exceção global em `main.py` melhoram a experiência de quem consome a API. + +- **Tarefas em Background (`BackgroundTasks`)**: A atualização da gamificação é feita em segundo plano. Isso proporciona uma resposta mais rápida ao usuário no momento do registro, pois ele não precisa esperar o cálculo da sequência ser finalizado. + +--- + +## Como Usar + +### Pré-requisitos + +- Python 3.11 ou superior + +### Instalação e Execução + +1. **Clone o repositório e entre na pasta:** + ```bash + git clone https://github.com/seu-usuario/focus_log.git + cd focus_log + ``` + +2. **Crie e ative um ambiente virtual:** + ```bash + python -m venv venv + # Windows: + venv\Scripts\activate + # macOS/Linux: + source venv/bin/activate + ``` + +3. **Instale as dependências:** + ```bash + pip install -r requirements.txt + ``` + +4. **Crie o arquivo de ambiente local:** + Este passo é crucial para definir a conexão com o banco de dados. + ```bash + # Windows + copy .env.example .env + # macOS/Linux + cp .env.example .env + ``` + +5. **Execute a aplicação:** + ```bash + uvicorn app.main:app --reload + ``` + A API estará disponível em `http://127.0.0.1:8000`. + +### Testando a API + +A forma mais fácil de interagir com a API é através da **documentação interativa (Swagger UI)**, disponível em: + +[**http://127.0.0.1:8000/docs**](http://127.0.0.1:8000/docs) + +Lá você pode testar todos os endpoints diretamente do seu navegador. + +--- + +## Exemplos de Uso (cURL) + +1. **Registrar uma nova sessão de foco:** + ```bash + curl -X 'POST' \ + 'http://127.0.0.1:8000/registro-foco' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "nivel_foco": 5, + "tempo_minutos": 90, + "comentario": "Desenvolvendo a funcionalidade de diagnóstico com foco total!", + "categoria": "coding", + "tags": "backend,python,api" + }' + ``` + +2. **Verificar o status da sua sequência (gamificação):** + ```bash + curl -X 'GET' 'http://127.0.0.1:8000/analise/status' -H 'accept: application/json' + ``` + +3. **Obter a análise de tags:** + ```bash + curl -X 'GET' 'http://127.0.0.1:8000/analise/tags' -H 'accept: application/json' + ``` + +4. **Obter o diagnóstico de produtividade:** + ```bash + curl -X 'GET' \ + 'http://127.0.0.1:8000/diagnostico-produtividade' \ + -H 'accept: application/json' + ``` + +--- + +## Como Rodar os Testes + +Para garantir a qualidade e o funcionamento correto da API, execute os testes automatizados com `pytest`. + +```bash +pytest +``` diff --git a/focus_log/app/database.py b/focus_log/app/database.py new file mode 100644 index 0000000..c884086 --- /dev/null +++ b/focus_log/app/database.py @@ -0,0 +1,29 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from dotenv import load_dotenv + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +engine = create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + """ + Obtém uma sessão do banco de dados. + + Yields: + Session: A sessão do banco de dados. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/focus_log/app/main.py b/focus_log/app/main.py new file mode 100644 index 0000000..902d663 --- /dev/null +++ b/focus_log/app/main.py @@ -0,0 +1,36 @@ +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from .database import engine, Base +from .routers import registro, diagnostico, analise + +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="Focus Log API", + description="Uma API para registrar e analisar sessões de foco, com gamificação e análise inteligente.", + version="2.0.0" +) + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """ + Handler global para erros de validação do Pydantic. + """ + detalhes = [] + for error in exc.errors(): + campo = " -> ".join(map(str, error["loc"])) + mensagem = error["msg"] + detalhes.append({"campo": campo, "mensagem": mensagem}) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"erro": "Dados inválidos", "detalhes": detalhes}, + ) + +app.include_router(registro.router, tags=["Registros de Foco"]) +app.include_router(diagnostico.router, tags=["Diagnóstico de Produtividade"]) +app.include_router(analise.router) + +@app.get("/", include_in_schema=False) +def read_root(): + return {"message": "Bem-vindo à Focus Log API v2! Acesse /docs para a documentação."} diff --git a/focus_log/app/models.py b/focus_log/app/models.py new file mode 100644 index 0000000..135badc --- /dev/null +++ b/focus_log/app/models.py @@ -0,0 +1,44 @@ +import enum +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Enum, + func, +) +from .database import Base + + +class StatusApp(Base): + """ + Modelo da tabela `status_app` para gamificação. + Armazena a sequência de dias com registros. + """ + __tablename__ = "status_app" + + id = Column(Integer, primary_key=True) + sequencia_atual = Column(Integer, default=0) + sequencia_maxima = Column(Integer, default=0) + data_ultimo_registro = Column(DateTime, nullable=True) + + +class CategoriaEnum(str, enum.Enum): + coding = "coding" + reuniao = "reuniao" + estudo = "estudo" + outro = "outro" + +class RegistroFoco(Base): + """ + Modelo da tabela `registros_foco`. + """ + __tablename__ = "registros_foco" + + id = Column(Integer, primary_key=True, index=True) + nivel_foco = Column(Integer, nullable=False) + tempo_minutos = Column(Integer, nullable=False) + comentario = Column(String(500), nullable=False) + categoria = Column(Enum(CategoriaEnum), default=CategoriaEnum.outro, nullable=False) + tags = Column(String(200), nullable=True) + criado_em = Column(DateTime, default=func.now(), nullable=False) diff --git a/focus_log/app/repository.py b/focus_log/app/repository.py new file mode 100644 index 0000000..425ece2 --- /dev/null +++ b/focus_log/app/repository.py @@ -0,0 +1,140 @@ +from sqlalchemy.orm import Session +from sqlalchemy import func +from . import models, schemas +from typing import List, Optional + +def create_registro_foco(db: Session, registro: schemas.RegistroFocoCreate) -> models.RegistroFoco: + """ + Cria um novo registro de foco no banco de dados. + + Args: + db (Session): A sessão do banco de dados. + registro (schemas.RegistroFocoCreate): Os dados do registro a ser criado. + + Returns: + models.RegistroFoco: O registro de foco criado. + """ + db_registro = models.RegistroFoco(**registro.model_dump()) + db.add(db_registro) + db.commit() + db.refresh(db_registro) + return db_registro + +def get_all_registros_foco(db: Session) -> List[models.RegistroFoco]: + """ + Obtém todos os registros de foco do banco de dados. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + List[models.RegistroFoco]: Uma lista de todos os registros de foco. + """ + return db.query(models.RegistroFoco).all() + +def get_total_registros(db: Session) -> int: + """ + Obtém o número total de registros de foco. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + int: O número total de registros. + """ + return db.query(models.RegistroFoco).count() + +def get_media_nivel_foco(db: Session) -> Optional[float]: + """ + Calcula a média do nível de foco. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + Optional[float]: A média do nível de foco ou None se não houver registros. + """ + return db.query(func.avg(models.RegistroFoco.nivel_foco)).scalar() + +def get_tempo_total_minutos(db: Session) -> int: + """ + Calcula o tempo total em minutos de todos os registros. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + int: O tempo total em minutos. + """ + return db.query(func.sum(models.RegistroFoco.tempo_minutos)).scalar() or 0 + +def get_melhor_sessao(db: Session) -> Optional[models.RegistroFoco]: + """ + Obtém a sessão com o maior nível de foco. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + Optional[models.RegistroFoco]: A melhor sessão de foco ou None. + """ + return db.query(models.RegistroFoco).order_by(models.RegistroFoco.nivel_foco.desc()).first() + +def get_pior_sessao(db: Session) -> Optional[models.RegistroFoco]: + """ + Obtém a sessão com o menor nível de foco. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + Optional[models.RegistroFoco]: A pior sessão de foco ou None. + """ + return db.query(models.RegistroFoco).order_by(models.RegistroFoco.nivel_foco.asc()).first() + +def get_distribuicao_categorias(db: Session) -> dict: + """ + Obtém a distribuição de registros por categoria. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + dict: Um dicionário com a contagem de registros por categoria. + """ + dist = ( + db.query(models.RegistroFoco.categoria, func.count(models.RegistroFoco.id)) + .group_by(models.RegistroFoco.categoria) + .all() + ) + return {categoria.name: count for categoria, count in dist} + + +def get_status_app(db: Session) -> Optional[models.StatusApp]: + """ + Obtém o status da aplicação (gamificação). + + Args: + db (Session): A sessão do banco de dados. + + Returns: + Optional[models.StatusApp]: O status da aplicação ou None. + """ + return db.query(models.StatusApp).first() + + +def create_status_app(db: Session) -> models.StatusApp: + """ + Cria o registro inicial de status da aplicação. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + models.StatusApp: O novo status da aplicação. + """ + db_status = models.StatusApp(sequencia_atual=0, sequencia_maxima=0) + db.add(db_status) + db.commit() + db.refresh(db_status) + return db_status diff --git a/focus_log/app/routers/__init__.py b/focus_log/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/focus_log/app/routers/analise.py b/focus_log/app/routers/analise.py new file mode 100644 index 0000000..90221b8 --- /dev/null +++ b/focus_log/app/routers/analise.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from .. import schemas, service +from ..database import get_db + +router = APIRouter( + prefix="/analise", + tags=["Análise e Gamificação"] +) + +@router.get("/status", response_model=schemas.StatusOut) +def get_status(db: Session = Depends(get_db)): + """ + Obtém o status de gamificação do usuário. + + Retorna a sequência atual de dias com registros, a sequência máxima + e uma mensagem motivacional. + """ + return service.obter_status_app(db) + +@router.get("/tags", response_model=schemas.AnaliseTagsOut) +def get_analise_tags(db: Session = Depends(get_db)): + """ + Analisa as tags e as correlaciona com o nível de foco. + + Retorna um dicionário com as tags mais associadas a sessões de + alto e baixo foco, ajudando a identificar padrões de produtividade. + """ + return service.analisar_tags(db) diff --git a/focus_log/app/routers/diagnostico.py b/focus_log/app/routers/diagnostico.py new file mode 100644 index 0000000..793c2dc --- /dev/null +++ b/focus_log/app/routers/diagnostico.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +from .. import schemas, service +from ..database import get_db + +router = APIRouter() + +@router.get("/diagnostico-produtividade", response_model=schemas.DiagnosticoOut) +def obter_diagnostico(db: Session = Depends(get_db)): + """ + Gera um diagnóstico completo da produtividade. + + - Se o banco estiver vazio, retorna uma mensagem amigável. + - Caso contrário, calcula todas as métricas e retorna o diagnóstico. + """ + try: + diagnostico = service.obter_diagnostico_completo(db) + if not diagnostico: + return {"mensagem": "Nenhum registro ainda. Registre sua primeira sessão de foco!"} + return diagnostico + except SQLAlchemyError: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"erro": "Erro interno. Tente novamente."}, + ) diff --git a/focus_log/app/routers/registro.py b/focus_log/app/routers/registro.py new file mode 100644 index 0000000..d28f205 --- /dev/null +++ b/focus_log/app/routers/registro.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +from .. import schemas, repository, service +from ..database import get_db + +router = APIRouter() + +@router.post("/registro-foco", response_model=schemas.RegistroFocoOut, status_code=status.HTTP_201_CREATED) +def criar_registro_foco( + registro: schemas.RegistroFocoCreate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """ + Cria um novo registro de foco. + + - Valida a entrada com Pydantic. + - Persiste os dados no banco. + - Adiciona uma tarefa em background para atualizar a gamificação. + - Retorna o registro criado com status 201. + """ + try: + db_registro = repository.create_registro_foco(db=db, registro=registro) + background_tasks.add_task(service.atualizar_status_gamificacao, db) + return db_registro + except SQLAlchemyError: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"erro": "Erro interno. Tente novamente."}, + ) diff --git a/focus_log/app/schemas.py b/focus_log/app/schemas.py new file mode 100644 index 0000000..40614a6 --- /dev/null +++ b/focus_log/app/schemas.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from typing import Optional, Dict +from .models import CategoriaEnum + +class RegistroFocoCreate(BaseModel): + """ + Schema para criação de um novo registro de foco. + """ + nivel_foco: int = Field(..., ge=1, le=5, description="Nível de foco deve ser entre 1 (muito distraído) e 5 (flow).") + tempo_minutos: int = Field(..., gt=0, lt=1440, description="Tempo deve ser entre 1 e 1440 minutos.") + comentario: str = Field(..., min_length=3, max_length=500) + categoria: CategoriaEnum = Field(default=CategoriaEnum.outro) + tags: Optional[str] = Field(default=None, max_length=200) + +class RegistroFocoOut(BaseModel): + """ + Schema para saída de um registro de foco. + """ + id: int + nivel_foco: int + tempo_minutos: int + comentario: str + categoria: CategoriaEnum + tags: Optional[str] + criado_em: datetime + + model_config = ConfigDict(from_attributes=True) + +class DiagnosticoOut(BaseModel): + """ + Schema para o diagnóstico de produtividade. + """ + total_registros: int + media_nivel_foco: float + tempo_total_minutos: int + tempo_formatado: str + melhor_sessao: RegistroFocoOut + pior_sessao: RegistroFocoOut + distribuicao_categorias: Dict[str, int] + feedback: str + +class StatusOut(BaseModel): + """ + Schema para o status de gamificação (sequências). + """ + sequencia_atual: int + sequencia_maxima: int + mensagem: str + +class AnaliseTagsOut(BaseModel): + """ + Schema para a análise de tags. + """ + tags_alto_foco: Dict[str, float] + tags_baixo_foco: Dict[str, float] diff --git a/focus_log/app/service.py b/focus_log/app/service.py new file mode 100644 index 0000000..44a6a43 --- /dev/null +++ b/focus_log/app/service.py @@ -0,0 +1,176 @@ +from sqlalchemy.orm import Session +from . import repository, schemas +from typing import Optional, Dict, List +from datetime import date, timedelta +from collections import defaultdict + +def atualizar_status_gamificacao(db: Session): + """ + Atualiza a sequência de dias de foco (streak). + + Args: + db (Session): A sessão do banco de dados. + """ + status_app = repository.get_status_app(db) + if not status_app: + status_app = repository.create_status_app(db) + + hoje = date.today() + data_ultimo_registro = status_app.data_ultimo_registro.date() if status_app.data_ultimo_registro else None + + if data_ultimo_registro == hoje: + return # Já atualizado hoje + + if data_ultimo_registro == hoje - timedelta(days=1): + status_app.sequencia_atual += 1 + else: + status_app.sequencia_atual = 1 # Reinicia a sequência + + if status_app.sequencia_atual > status_app.sequencia_maxima: + status_app.sequencia_maxima = status_app.sequencia_atual + + status_app.data_ultimo_registro = hoje + db.commit() + +def obter_status_app(db: Session) -> schemas.StatusOut: + """ + Obtém o status de gamificação formatado para o schema de saída. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + schemas.StatusOut: O status de gamificação. + """ + status_app = repository.get_status_app(db) + if not status_app: + status_app = repository.create_status_app(db) + + mensagem = "Você ainda não registrou nenhuma sessão de foco. Registre a primeira para começar sua sequência!" + if status_app.sequencia_atual == 1: + mensagem = f"Parabéns por iniciar sua sequência! Você está há {status_app.sequencia_atual} dia focado. Continue amanhã! 🔥" + elif status_app.sequencia_atual > 1: + mensagem = f"Incrível! Você mantém uma sequência de {status_app.sequencia_atual} dias. Sua consistência é a chave para a produtividade. 🚀" + + # Mensagem especial se a sequência máxima foi batida + if status_app.sequencia_atual > 0 and status_app.sequencia_atual == status_app.sequencia_maxima: + mensagem += " E você acaba de bater seu recorde pessoal!" + + + return schemas.StatusOut( + sequencia_atual=status_app.sequencia_atual, + sequencia_maxima=status_app.sequencia_maxima, + mensagem=mensagem + ) + + +def analisar_tags(db: Session) -> schemas.AnaliseTagsOut: + """ + Analisa as tags e as correlaciona com o nível de foco. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + schemas.AnaliseTagsOut: A análise de tags. + """ + registros = repository.get_all_registros_foco(db) + if not registros: + return schemas.AnaliseTagsOut(tags_alto_foco={}, tags_baixo_foco={}) + + tags_foco: Dict[str, List[int]] = defaultdict(list) + + for r in registros: + if r.tags: + for tag in r.tags.split(','): + tag_limpa = tag.strip().lower() + if tag_limpa: + tags_foco[tag_limpa].append(r.nivel_foco) + + media_tags = {tag: sum(niveis) / len(niveis) for tag, niveis in tags_foco.items()} + + tags_alto_foco = {tag: round(media, 2) for tag, media in sorted(media_tags.items(), key=lambda item: item[1], reverse=True) if media >= 3.5} + tags_baixo_foco = {tag: round(media, 2) for tag, media in sorted(media_tags.items(), key=lambda item: item[1]) if media < 3.0} + + return schemas.AnaliseTagsOut( + tags_alto_foco=dict(list(tags_alto_foco.items())[:5]), # Limita a 5 + tags_baixo_foco=dict(list(tags_baixo_foco.items())[:5]) # Limita a 5 + ) + +def gerar_feedback(media: float, total_minutos: int) -> str: + """ + Gera uma mensagem de feedback detalhada e didática baseada na performance. + + Args: + media (float): A média do nível de foco. + total_minutos (int): O total de minutos focados. + + Returns: + str: A mensagem de feedback. + """ + if media < 2: + return "Sua média de foco está muito baixa, indicando muitas distrações. Tente usar a técnica Pomodoro (25 min de foco, 5 de pausa) e desligue as notificações. 🔕" + if media < 3: + return "Seu foco está abaixo do ideal. Isso pode ser sinal de cansaço. Pausas um pouco mais longas ou um café podem ajudar a recarregar. ☕" + if media < 4 and total_minutos < 60: + return "Você está fazendo sessões curtas com foco mediano. Para entrar em 'flow', tente criar blocos de tempo de pelo menos 25 minutos contínuos. ⏱️" + if media < 4: + return "Seu progresso é razoável! O próximo passo é identificar as principais fontes de distração durante as sessões e tentar eliminá-las. 🎯" + if media >= 4 and total_minutos >= 120: + return "Excelente! Você teve uma maratona produtiva de alto nível, mantendo o foco por um longo período. Continue assim! 🚀" + if media >= 4: + return "Ótimo trabalho mantendo um foco de alta qualidade! Se você conseguir encaixar mais blocos de tempo como este, seu dia será extremamente produtivo. 💪" + return "Continue registrando suas sessões para receber feedbacks cada vez mais precisos e acompanhar sua evolução." + + +def formatar_tempo(minutos: int) -> str: + """ + Formata minutos em uma string 'Xh Ymin'. + + Args: + minutos (int): O total de minutos. + + Returns: + str: A string formatada. + """ + if minutos is None: + return "0min" + h = minutos // 60 + m = minutos % 60 + if h > 0: + return f"{h}h {m}min" + return f"{m}min" + +def obter_diagnostico_completo(db: Session) -> Optional[schemas.DiagnosticoOut]: + """ + Cria o objeto de diagnóstico completo com todos os dados. + + Args: + db (Session): A sessão do banco de dados. + + Returns: + Optional[schemas.DiagnosticoOut]: O objeto de diagnóstico ou None se não houver dados. + """ + total_registros = repository.get_total_registros(db) + if total_registros == 0: + return None + + media_nivel_foco = repository.get_media_nivel_foco(db) + tempo_total_minutos = repository.get_tempo_total_minutos(db) + melhor_sessao = repository.get_melhor_sessao(db) + pior_sessao = repository.get_pior_sessao(db) + distribuicao_categorias = repository.get_distribuicao_categorias(db) + + feedback = gerar_feedback(media_nivel_foco, tempo_total_minutos) + tempo_formatado = formatar_tempo(tempo_total_minutos) + + return schemas.DiagnosticoOut( + total_registros=total_registros, + media_nivel_foco=round(media_nivel_foco, 2), + tempo_total_minutos=tempo_total_minutos, + tempo_formatado=tempo_formatado, + melhor_sessao=melhor_sessao, + pior_sessao=pior_sessao, + distribuicao_categorias=distribuicao_categorias, + feedback=feedback, + ) diff --git a/focus_log/requirements.txt b/focus_log/requirements.txt new file mode 100644 index 0000000..cf1c5fc --- /dev/null +++ b/focus_log/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.0 +pydantic>=2.0.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 +httpx>=0.27.0 diff --git a/focus_log/tests/__init__.py b/focus_log/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/focus_log/tests/test_analise.py b/focus_log/tests/test_analise.py new file mode 100644 index 0000000..4ca7181 --- /dev/null +++ b/focus_log/tests/test_analise.py @@ -0,0 +1,95 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.main import app +from app.database import Base, get_db +from datetime import date, timedelta + +# Configuração do banco de dados de teste +SQLALCHEMY_DATABASE_URL = "sqlite:///./test_analise.db" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Sobrescrever a dependência get_db para usar o banco de teste +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +@pytest.fixture(scope="function") +def client(): + """ + Fixture para criar um cliente de teste e gerenciar o banco de dados. + """ + Base.metadata.create_all(bind=engine) + with TestClient(app) as c: + yield c + Base.metadata.drop_all(bind=engine) + +# --- Testes de Gamificação --- + +def test_status_inicial(client): + """Testa o status inicial da gamificação com banco vazio.""" + response = client.get("/analise/status") + assert response.status_code == 200 + data = response.json() + assert data["sequencia_atual"] == 0 + assert data["sequencia_maxima"] == 0 + assert "Comece hoje" in data["mensagem"] + +def test_sequencia_de_foco(client): + """Testa a lógica de contagem de sequência (streak).""" + # Dia 1 + client.post("/registro-foco", json={"nivel_foco": 4, "tempo_minutos": 50, "comentario": "d1"}) + + response = client.get("/analise/status") + data = response.json() + assert data["sequencia_atual"] == 1 + assert data["sequencia_maxima"] == 1 + + # Adulterando o banco para simular o dia seguinte + from app.models import StatusApp + db = TestingSessionLocal() + status_app = db.query(StatusApp).first() + status_app.data_ultimo_registro = date.today() - timedelta(days=1) + db.commit() + db.close() + + # Dia 2 + client.post("/registro-foco", json={"nivel_foco": 4, "tempo_minutos": 50, "comentario": "d2"}) + response = client.get("/analise/status") + data = response.json() + assert data["sequencia_atual"] == 2 + assert data["sequencia_maxima"] == 2 + +# --- Testes de Análise de Tags --- + +def test_analise_tags_vazio(client): + """Testa a análise de tags com banco vazio.""" + response = client.get("/analise/tags") + assert response.status_code == 200 + assert response.json() == {"tags_alto_foco": {}, "tags_baixo_foco": {}} + +def test_analise_tags_com_dados(client): + """Testa a correlação de tags com o nível de foco.""" + client.post("/registro-foco", json={"nivel_foco": 5, "tempo_minutos": 90, "comentario": "c", "tags": "python, backend"}) + client.post("/registro-foco", json={"nivel_foco": 4, "tempo_minutos": 60, "comentario": "c", "tags": "python, estudo"}) + client.post("/registro-foco", json={"nivel_foco": 2, "tempo_minutos": 30, "comentario": "c", "tags": "reuniao, cansado"}) + client.post("/registro-foco", json={"nivel_foco": 1, "tempo_minutos": 15, "comentario": "c", "tags": "cansado"}) + + response = client.get("/analise/tags") + assert response.status_code == 200 + data = response.json() + + assert "python" in data["tags_alto_foco"] + assert data["tags_alto_foco"]["python"] == 4.5 + + assert "cansado" in data["tags_baixo_foco"] + assert data["tags_baixo_foco"]["cansado"] == 1.5 diff --git a/focus_log/tests/test_diagnostico.py b/focus_log/tests/test_diagnostico.py new file mode 100644 index 0000000..7fda907 --- /dev/null +++ b/focus_log/tests/test_diagnostico.py @@ -0,0 +1,69 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.main import app +from app.database import Base, get_db + +# Reutilizando a configuração do banco de dados de teste de test_registro +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +@pytest.fixture(scope="function") +def client(): + """ + Fixture para criar um cliente de teste e gerenciar o banco de dados. + """ + Base.metadata.create_all(bind=engine) + with TestClient(app) as c: + yield c + Base.metadata.drop_all(bind=engine) + +# --- Testes para o endpoint GET /diagnostico-produtividade --- + +def test_diagnostico_banco_vazio(client): + """ + Testa o GET em /diagnostico-produtividade com o banco de dados vazio. + """ + response = client.get("/diagnostico-produtividade") + assert response.status_code == 200 + assert response.json() == {"mensagem": "Nenhum registro ainda. Registre sua primeira sessão de foco!"} + +def test_diagnostico_com_dados(client): + """ + Testa o GET em /diagnostico-produtividade após inserir alguns registros. + """ + # Inserir dados de teste + client.post("/registro-foco", json={"nivel_foco": 5, "tempo_minutos": 90, "comentario": "Deep work em feature X", "categoria": "coding"}) + client.post("/registro-foco", json={"nivel_foco": 2, "tempo_minutos": 30, "comentario": "Reunião de alinhamento", "categoria": "reuniao"}) + client.post("/registro-foco", json={"nivel_foco": 4, "tempo_minutos": 50, "comentario": "Estudo de documentação", "categoria": "estudo"}) + + response = client.get("/diagnostico-produtividade") + assert response.status_code == 200 + data = response.json() + + # Verificar os campos principais + assert data["total_registros"] == 3 + assert data["tempo_total_minutos"] == 170 + assert data["tempo_formatado"] == "2h 50min" + assert data["media_nivel_foco"] == round((5 + 2 + 4) / 3, 2) + + # Verificar melhor e pior sessão + assert data["melhor_sessao"]["nivel_foco"] == 5 + assert data["pior_sessao"]["nivel_foco"] == 2 + + # Verificar distribuição e feedback + assert data["distribuicao_categorias"] == {"coding": 1, "reuniao": 1, "estudo": 1} + assert data["feedback"] == "Maratona produtiva de alto nível! 🚀" diff --git a/focus_log/tests/test_registro.py b/focus_log/tests/test_registro.py new file mode 100644 index 0000000..412e22c --- /dev/null +++ b/focus_log/tests/test_registro.py @@ -0,0 +1,88 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.main import app +from app.database import Base, get_db + +# Configuração do banco de dados de teste +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Sobrescrever a dependência get_db para usar o banco de teste +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +@pytest.fixture(scope="function") +def client(): + """ + Fixture para criar um cliente de teste e gerenciar o banco de dados. + """ + Base.metadata.create_all(bind=engine) + with TestClient(app) as c: + yield c + Base.metadata.drop_all(bind=engine) + +# --- Testes para o endpoint POST /registro-foco --- + +def test_criar_registro_foco_sucesso(client): + """ + Testa a criação de um registro de foco com dados válidos. + """ + response = client.post( + "/registro-foco", + json={ + "nivel_foco": 4, + "tempo_minutos": 45, + "comentario": "Desenvolvimento da API", + "categoria": "coding", + "tags": "backend,fastapi" + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["nivel_foco"] == 4 + assert data["comentario"] == "Desenvolvimento da API" + assert "id" in data + assert "criado_em" in data + +@pytest.mark.parametrize("nivel_foco_invalido", [0, 6, -1]) +def test_criar_registro_foco_nivel_invalido(client, nivel_foco_invalido): + """ + Testa a criação com nível de foco fora do range (1-5). + """ + response = client.post( + "/registro-foco", + json={"nivel_foco": nivel_foco_invalido, "tempo_minutos": 30, "comentario": "Teste"}, + ) + assert response.status_code == 422 + +@pytest.mark.parametrize("tempo_invalido", [0, -10, 1441]) +def test_criar_registro_foco_tempo_invalido(client, tempo_invalido): + """ + Testa a criação com tempo em minutos fora do range permitido. + """ + response = client.post( + "/registro-foco", + json={"nivel_foco": 3, "tempo_minutos": tempo_invalido, "comentario": "Teste"}, + ) + assert response.status_code == 422 + +def test_criar_registro_foco_comentario_curto(client): + """ + Testa a criação com um comentário muito curto. + """ + response = client.post( + "/registro-foco", + json={"nivel_foco": 3, "tempo_minutos": 25, "comentario": "ab"}, + ) + assert response.status_code == 422