mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:40:37 +00:00
Phase 4: task-to-goal integration (Vision side)
Build the boundary the ImpactFlow core time-tracker plugs into. A task maps to a foundation — one of the six stable profile elements (love, strength, mission, vocation, short_term, long_term) — so the tracker can ask "which goal does this build toward?" and post the answer back to Vision. - Models + migration 006: task_mapping (one row per logged time entry). - app/services/foundations.py: the six foundations + a pure, testable work-pattern aggregator (rollup) and a plain-language summary. - app/routers/integration.py (user-scoped; tracker calls as the user or via X-API-Key): GET /foundations, POST/GET /task-mappings, GET /work-patterns?days=N (per-foundation minutes/share/neglected). - Reminder engine now pulls from real work patterns: CheckinCoach takes an optional work-pattern summary (last 14 days) and reflects where time has gone against the person's own words — an observation, never a verdict. - Frontend: dashboard.html (time per foundation + neglected); linked from profile.html. Documented the core-tracker integration contract in the README. Phase 4 completes the Vision module's roadmap on the Discovery side; the core tracker integrates by calling these endpoints. Tests: 86 passing (added pure-aggregator, integration-endpoint, and work-pattern-into-check-in tests; run in-container). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""Phase 4 integration routes: the boundary the ImpactFlow core time-tracker
|
||||
plugs into.
|
||||
|
||||
When a user logs time in the core tracker, the tracker asks "which goal does
|
||||
this build toward?" — fetching the options from ``GET /foundations`` — and
|
||||
posts the answer to ``POST /task-mappings``. ``GET /work-patterns`` rolls those
|
||||
up to feed the coaching reminder engine and the goal dashboard.
|
||||
|
||||
All routes are user-scoped via the dual-auth dependency; the tracker calls as
|
||||
the user (forwarded session/JWT) or, service-to-service, with ``X-API-Key``.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import DiscoveryProfile, TaskMapping, User
|
||||
from app.services.foundations import (
|
||||
FOUNDATION_TO_FIELD,
|
||||
FOUNDATIONS,
|
||||
rollup,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/discovery/integration", tags=["integration"])
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _latest_profile(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> DiscoveryProfile | None:
|
||||
stmt = (
|
||||
select(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user_id)
|
||||
.order_by(DiscoveryProfile.generated_at.desc())
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
@router.get("/foundations", response_model=list[schemas.FoundationOut])
|
||||
async def list_foundations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The mappable foundations for the user's latest profile, with the
|
||||
person's own text — what the tracker shows as "which goal does this build
|
||||
toward?"."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
return [
|
||||
schemas.FoundationOut(
|
||||
key=key,
|
||||
label=label,
|
||||
text=getattr(profile, FOUNDATION_TO_FIELD[key], None),
|
||||
)
|
||||
for key, label in FOUNDATIONS.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/task-mappings", response_model=schemas.TaskMappingOut)
|
||||
async def create_task_mapping(
|
||||
payload: schemas.TaskMappingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Record one logged unit of work, mapped to the foundation it builds
|
||||
toward. Called by the core tracker when time is logged."""
|
||||
if payload.foundation not in FOUNDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid foundation: {payload.foundation!r}. "
|
||||
f"Allowed: {sorted(FOUNDATIONS)}",
|
||||
)
|
||||
if payload.minutes < 0:
|
||||
raise HTTPException(status_code=400, detail="minutes must be >= 0")
|
||||
|
||||
mapping = TaskMapping(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
external_task_id=payload.external_task_id,
|
||||
task_label=payload.task_label or None,
|
||||
foundation=payload.foundation,
|
||||
minutes=payload.minutes,
|
||||
occurred_at=payload.occurred_at or _now(),
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
await db.refresh(mapping)
|
||||
return schemas.TaskMappingOut.model_validate(mapping, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/task-mappings", response_model=list[schemas.TaskMappingOut])
|
||||
async def list_task_mappings(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
cutoff = _now() - timedelta(days=days)
|
||||
stmt = (
|
||||
select(TaskMapping)
|
||||
.where(TaskMapping.user_id == user.id)
|
||||
.where(TaskMapping.occurred_at >= cutoff)
|
||||
.order_by(TaskMapping.occurred_at.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
schemas.TaskMappingOut.model_validate(r, from_attributes=True)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def work_patterns_for(
|
||||
db: AsyncSession, user_id: str, days: int
|
||||
) -> dict:
|
||||
"""Roll up the user's task mappings over the window. Shared with the
|
||||
coaching check-in engine."""
|
||||
cutoff = _now() - timedelta(days=days)
|
||||
stmt = (
|
||||
select(TaskMapping)
|
||||
.where(TaskMapping.user_id == user_id)
|
||||
.where(TaskMapping.occurred_at >= cutoff)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
entries = [
|
||||
{
|
||||
"foundation": r.foundation,
|
||||
"minutes": r.minutes,
|
||||
"occurred_at": r.occurred_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return rollup(entries)
|
||||
|
||||
|
||||
@router.get("/work-patterns", response_model=schemas.WorkPatternsOut)
|
||||
async def get_work_patterns(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Per-foundation work patterns over the window (powers the dashboard and
|
||||
the coaching reminder engine)."""
|
||||
summary = await work_patterns_for(db, user.id, days)
|
||||
return schemas.WorkPatternsOut(window_days=days, **summary)
|
||||
Reference in New Issue
Block a user