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:
Joel Salmon
2026-06-16 21:24:58 -05:00
parent 50453901b3
commit c4fc1cccd7
16 changed files with 857 additions and 5 deletions
+2
View File
@@ -16,6 +16,7 @@ from app.routers import (
auth as auth_router,
coaching,
discovery,
integration,
)
from app.routers.activity import prune_old_activity
from app.tracking import ActivityTrackingMiddleware
@@ -70,6 +71,7 @@ app.include_router(auth_router.router)
app.include_router(activity.router)
app.include_router(discovery.router)
app.include_router(coaching.router)
app.include_router(integration.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+24
View File
@@ -246,3 +246,27 @@ class CoachingCheckin(Base):
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
class TaskMapping(Base):
"""Phase 4: a logged unit of work from the ImpactFlow core time-tracker,
mapped to the profile foundation it builds toward. One row per time entry;
the work-pattern aggregation rolls these up per foundation."""
__tablename__ = "task_mapping"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
# Opaque id of the task in the core tracker (not an FK; external system).
external_task_id: Mapped[str] = mapped_column(String, nullable=False)
task_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# One of foundations.FOUNDATIONS: love | strength | mission | vocation |
# short_term | long_term.
foundation: Mapped[str] = mapped_column(String, nullable=False, index=True)
minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
occurred_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+11 -1
View File
@@ -27,6 +27,11 @@ from app.services.coaching import (
CheckinError,
generate_preferences,
)
from app.services.foundations import work_pattern_text
from app.routers.integration import work_patterns_for
# Look-back window (days) for the work-pattern signal fed into a check-in.
_WORK_PATTERN_DAYS = 14
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
@@ -206,8 +211,13 @@ async def _generate_checkin(
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
coach = CheckinCoach(api_key=api_key, model=model)
# Phase 4: feed recent work patterns into the check-in so it can reflect
# where time has actually gone against the person's stated direction.
summary = await work_patterns_for(db, user_id, _WORK_PATTERN_DAYS)
body = await coach.generate(
_profile_dict(profile), _prefs_out(prefs).model_dump()
_profile_dict(profile),
_prefs_out(prefs).model_dump(),
work_patterns=work_pattern_text(summary, _WORK_PATTERN_DAYS),
)
checkin = CoachingCheckin(
id=str(uuid.uuid4()),
+153
View File
@@ -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)
+44
View File
@@ -150,6 +150,50 @@ class RunCheckinsResponse(BaseModel):
generated: int
# -- Phase 4: task-to-goal integration ---------------------------------------
class FoundationOut(BaseModel):
key: str
label: str
text: Optional[str] = None
class TaskMappingCreate(BaseModel):
external_task_id: str
foundation: str
minutes: int = 0
task_label: str = ""
# When the work happened; defaults to now if omitted.
occurred_at: Optional[datetime] = None
class TaskMappingOut(BaseModel):
id: str
external_task_id: str
task_label: Optional[str] = None
foundation: str
minutes: int
occurred_at: datetime
created_at: datetime
class FoundationPattern(BaseModel):
foundation: str
label: str
minutes: int
task_count: int
last_at: Optional[datetime] = None
share: float
class WorkPatternsOut(BaseModel):
window_days: int
total_minutes: int
by_foundation: list[FoundationPattern]
neglected: list[str]
class ConversationResponse(BaseModel):
id: str
user_id: str
+14 -2
View File
@@ -97,8 +97,12 @@ THE PERSON'S OWN WORDS (their profile):
HOW THEY WANT TO BE COACHED:
{prefs}
RECENT WORK PATTERNS (from their time tracker, may be empty):
{work_patterns}
YOUR TASK:
- Write a short check-in (3-5 sentences) that QUOTES the person's own words back to them — a specific phrase from their goals or their sense of purpose, in quotation marks.
- If recent work patterns are given, you may gently reflect what they show (e.g. where their time has and hasn't gone) — but only as an observation to check against their own words. Never tell them it is good or bad.
- Then ask, gently and openly, whether that direction still feels true for them right now. Invite them to say if anything has shifted.
ABSOLUTE RULES (mirror, not compass):
@@ -140,12 +144,20 @@ class CheckinCoach:
)
async def generate(
self, profile: Dict[str, Any], prefs: Dict[str, Any]
self,
profile: Dict[str, Any],
prefs: Dict[str, Any],
work_patterns: str | None = None,
) -> str:
"""Produce the check-in body text. Raises CheckinError on failure."""
"""Produce the check-in body text. Raises CheckinError on failure.
``work_patterns`` is an optional plain-language summary of recent logged
work (Phase 4); when present the coach may reflect it back as an
observation to check against the person's own words."""
system = SYSTEM_PROMPT.format(
profile=self._profile_block(profile),
prefs=self._prefs_block(prefs),
work_patterns=work_patterns or "(no recent work logged)",
style=prefs.get("coaching_style", "warm"),
prefer_questions=prefs.get("prefer_questions_over_directives", True),
)
+99
View File
@@ -0,0 +1,99 @@
"""Phase 4 foundations: the six profile elements a task can build toward, and
a pure work-pattern aggregator.
A "foundation" is one of the stable parts of a person's profile. The core
time-tracker asks "which of these does this task build toward?" and posts the
mapping back; the aggregator rolls those mappings up per foundation to feed the
coaching reminder engine and the goal dashboard.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
# foundation key -> human label
FOUNDATIONS = {
"love": "What you love",
"strength": "What you're good at",
"mission": "What the world needs",
"vocation": "What you can be paid for",
"short_term": "Near-term goals (612mo)",
"long_term": "Long-term goals (35yr)",
}
# foundation key -> the DiscoveryProfile prose field it reflects
FOUNDATION_TO_FIELD = {
"love": "love_summary",
"strength": "strength_summary",
"mission": "mission_summary",
"vocation": "vocation_summary",
"short_term": "short_term_goals",
"long_term": "long_term_goals",
}
def rollup(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Aggregate task mappings into per-foundation work patterns.
Args:
entries: each ``{"foundation": str, "minutes": int,
"occurred_at": datetime}``. Already filtered to the desired window
by the caller.
Returns:
``{"total_minutes", "by_foundation": [...], "neglected": [...]}`` where
``by_foundation`` covers all six foundations (zero included), sorted by
minutes descending, and ``neglected`` lists foundations with no minutes.
"""
agg: Dict[str, Dict[str, Any]] = {
key: {"minutes": 0, "task_count": 0, "last_at": None}
for key in FOUNDATIONS
}
for e in entries:
key = e.get("foundation")
if key not in agg:
continue # ignore unknown foundations defensively
minutes = int(e.get("minutes") or 0)
agg[key]["minutes"] += minutes
agg[key]["task_count"] += 1
occurred = e.get("occurred_at")
if occurred is not None:
prev = agg[key]["last_at"]
if prev is None or occurred > prev:
agg[key]["last_at"] = occurred
total = sum(v["minutes"] for v in agg.values())
by_foundation = [
{
"foundation": key,
"label": FOUNDATIONS[key],
"minutes": v["minutes"],
"task_count": v["task_count"],
"last_at": v["last_at"],
"share": (v["minutes"] / total) if total else 0.0,
}
for key, v in agg.items()
]
by_foundation.sort(key=lambda r: r["minutes"], reverse=True)
neglected = [r["foundation"] for r in by_foundation if r["minutes"] == 0]
return {
"total_minutes": total,
"by_foundation": by_foundation,
"neglected": neglected,
}
def work_pattern_text(summary: Dict[str, Any], window_days: int) -> Optional[str]:
"""A short plain-language summary of recent work patterns for the coach to
reference. Returns None when there is no logged activity."""
if not summary or summary.get("total_minutes", 0) <= 0:
return None
active = [r for r in summary["by_foundation"] if r["minutes"] > 0]
spent = "; ".join(
f"{r['label']} {r['minutes']} min ({round(r['share'] * 100)}%)"
for r in active
)
parts = [f"In the last {window_days} days you logged time toward: {spent}."]
neglected = summary.get("neglected") or []
if neglected:
names = ", ".join(FOUNDATIONS[k] for k in neglected)
parts.append(f"Nothing was logged toward: {names}.")
return " ".join(parts)
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Goal Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<div class="wrap">
<div class="brand">ImpactFlow · Where Your Time Goes</div>
<div class="nav" style="justify-content:flex-end;gap:8px">
<label for="days" style="font-size:.9rem;color:var(--navy-soft)">Window</label>
<select id="days" class="pref-select" style="width:auto">
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div id="content"><p>Loading…</p></div>
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
</div>
<script>
const content = document.getElementById("content");
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function fmt(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
function render(data) {
if (data.total_minutes === 0) {
content.innerHTML = `<div class="triad-block"><p>No work has been
logged toward your foundations yet. As you log time in ImpactFlow and
tag which goal each task builds toward, it will show up here.</p></div>`;
return;
}
const max = Math.max(...data.by_foundation.map((f) => f.minutes), 1);
const bars = data.by_foundation
.map((f) => {
const pct = Math.round((f.minutes / max) * 100);
const share = Math.round(f.share * 100);
return `
<div class="bar-row">
<div class="bar-label">${escapeHtml(f.label)}</div>
<div class="bar-track">
<div class="bar-fill" style="width:${pct}%"></div>
</div>
<div class="bar-val">${f.minutes ? fmt(f.minutes) : "—"}${
f.minutes ? ` · ${share}%` : ""
}</div>
</div>`;
})
.join("");
const neglected = data.neglected.length
? `<p class="edit-help">No time logged toward:
${data.neglected.map((n) => escapeHtml(labelFor(n, data))).join(", ")}.
Does that match where you want your energy to go?</p>`
: "";
content.innerHTML = `
<p class="section-label">Time toward each foundation · last ${data.window_days} days</p>
<div class="bars">${bars}</div>
${neglected}`;
}
function labelFor(key, data) {
const f = data.by_foundation.find((x) => x.foundation === key);
return f ? f.label : key;
}
async function load() {
const days = document.getElementById("days").value;
content.innerHTML = "<p>Loading…</p>";
try {
const res = await authedFetch(
`/discovery/integration/work-patterns?days=${days}`
);
if (!res.ok) {
const d = await res.json().catch(() => ({ detail: "Failed" }));
throw new Error(d.detail || "Failed");
}
render(await res.json());
} catch (e) {
content.innerHTML = `<div class="error-box">${escapeHtml(e.message)}</div>`;
}
}
document.getElementById("days").addEventListener("change", load);
load();
</script>
</body>
</html>
+3
View File
@@ -145,6 +145,9 @@
const coachingLink =
`<p class="back-link" style="text-align:center;margin-top:10px">
<a href="/static/coaching.html">Coaching preferences & check-ins →</a>
</p>
<p class="back-link" style="text-align:center;margin-top:10px">
<a href="/static/dashboard.html">Where your time goes →</a>
</p>`;
content.innerHTML = `
+45
View File
@@ -398,6 +398,51 @@ textarea.edit {
}
}
.bars {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 22px;
}
.bar-row {
display: grid;
grid-template-columns: 180px 1fr 110px;
align-items: center;
gap: 12px;
}
.bar-label {
font-size: 0.95rem;
color: var(--navy);
}
.bar-track {
background: rgba(13, 27, 42, 0.08);
border-radius: 8px;
height: 18px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--gold);
border-radius: 8px;
min-width: 2px;
}
.bar-val {
text-align: right;
font-size: 0.9rem;
color: var(--navy-soft);
}
@media (max-width: 560px) {
.bar-row {
grid-template-columns: 120px 1fr 80px;
}
}
.back-link {
margin-top: 8px;
}