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
+63 -1
View File
@@ -46,6 +46,10 @@ If you need to explain this app in detail, use this mental model:
coaching preferences (auto-derived from their profile) and receive periodic
check-ins that quote their own words and ask if their direction still holds.
A weekly cron calls `POST /discovery/coaching/run` to generate due check-ins.
14. (Phase 4) The ImpactFlow core time-tracker maps each logged task to a
profile foundation via `POST /discovery/integration/task-mappings`. The
rolled-up work patterns drive `/static/dashboard.html` and are fed into the
coaching check-ins so they can reflect where time has actually gone.
Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and
authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header
@@ -147,21 +151,27 @@ Important files:
| `app/static/profile.html` | Browser-based profile display, edit, and confirm actions; links to reflection |
| `app/static/reflect.html` | Phase 2 AI-coach reflection chat (mirror loop, applies revisions, affirm) |
| `app/static/coaching.html` | Phase 3 coaching preferences form + check-in feed |
| `app/static/dashboard.html` | Phase 4 goal dashboard: time per foundation + neglected ones |
| `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
| `app/static/style.css` | Shared UI styling |
| `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering |
| `app/services/coaching.py` | Deterministic preference generator + `CheckinCoach` (Anthropic check-in text) |
| `app/services/foundations.py` | The six foundations + pure work-pattern aggregator (Phase 4) |
| `app/routers/coaching.py` | Phase 3 coaching routes: preferences, check-ins, weekly batch `/run` |
| `app/routers/integration.py` | Phase 4 task-to-goal integration: foundations, task-mappings, work-patterns |
| `alembic/versions/001_initial.py` | Initial database schema migration |
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
| `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` |
| `alembic/versions/004_add_reflection.py` | Adds `reflection_message` and `profile_revision` tables (Phase 2) |
| `alembic/versions/005_add_coaching.py` | Adds `coaching_preferences` and `coaching_checkin` tables (Phase 3) |
| `alembic/versions/006_add_task_mapping.py` | Adds the `task_mapping` table (Phase 4) |
| `tests/conftest.py` | Shared `app_client` fixture (isolated app + temp DB) |
| `tests/test_extractor.py` | Unit tests for extraction plumbing, goals, and retry behavior |
| `tests/test_reflector.py` | Unit tests for `ReflectionCoach` (mirror, revision filtering, retry) |
| `tests/test_coaching_prefs.py` | Unit tests for the deterministic coaching-preference generator |
| `tests/test_coaching.py` | Tests for coaching endpoints (preferences, check-ins, due-logic batch) |
| `tests/test_coaching.py` | Tests for coaching endpoints (preferences, check-ins, due-logic batch, work-pattern wiring) |
| `tests/test_foundations.py` | Unit tests for the pure work-pattern aggregator |
| `tests/test_integration.py` | Tests for the task-to-goal integration endpoints |
| `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list |
| `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) |
| `tests/test_reflection.py` | Tests for the reflection endpoints (turns, applied revisions, lock/`409`, history) |
@@ -427,6 +437,30 @@ prescribes. The person's answer is recorded in `still_valid`.
cadence is due. Eligible = coaching cadence not `off` and an affirmed
(locked) profile; due = no prior check-in or the cadence interval has elapsed.
### 10. Task-to-Goal Integration (Phase 4)
This is 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`.
**Core-tracker contract:** when a user logs time, the tracker (1) fetches the
options from `GET /discovery/integration/foundations`, (2) asks the person
"which goal does this build toward?", and (3) posts the answer to
`POST /discovery/integration/task-mappings` with `{external_task_id,
foundation, minutes, task_label?, occurred_at?}`. It calls these endpoints as
the user (forwarded session/JWT) or service-to-service with `X-API-Key`.
- `GET /discovery/integration/foundations` — the six foundations with the
person's own text (what the tracker shows). `404` if no profile.
- `POST /discovery/integration/task-mappings` — record one logged unit of work.
- `GET /discovery/integration/task-mappings?days=N` — the user's mappings.
- `GET /discovery/integration/work-patterns?days=N` — per-foundation rollup
(minutes, share, task count, last activity) plus `neglected` foundations.
Powers the goal dashboard and feeds the coaching reminder engine: a check-in
is given a plain-language summary of the last 14 days so it can reflect where
time has and hasn't gone — as an observation to check against the person's
own words, never a verdict (mirror, not compass).
## API Reference
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
@@ -469,6 +503,10 @@ clients.) `/api/auth/login`, `/api/auth/callback`, `/health`, `/`, and
| `POST` | `/discovery/coaching/checkins` | yes | Generate a check-in now |
| `PUT` | `/discovery/coaching/checkins/{id}/respond` | yes | Record "is your direction still valid?" |
| `POST` | `/discovery/coaching/run` | admin | Weekly batch: generate due check-ins for eligible users |
| `GET` | `/discovery/integration/foundations` | yes | The six mappable foundations with the person's own text |
| `POST` | `/discovery/integration/task-mappings` | yes | Record a logged unit of work mapped to a foundation |
| `GET` | `/discovery/integration/task-mappings` | yes | List the user's task mappings in a window |
| `GET` | `/discovery/integration/work-patterns` | yes | Per-foundation work-pattern rollup over a window |
## Data Model
@@ -622,6 +660,22 @@ A periodic coaching check-in and the person's response (migration `005`).
| `response_note` | text nullable | Optional note with their response |
| `acknowledged_at` | datetime nullable | When they responded |
### `task_mapping`
One logged unit of work from the core tracker, mapped to a foundation
(migration `006`).
| Column | Type | Notes |
| --- | --- | --- |
| `id` | string | UUID primary key |
| `user_id` | string | FK to `users.id`, indexed |
| `external_task_id` | string | Opaque task id from the core tracker (not an FK) |
| `task_label` | string nullable | Human label of the task |
| `foundation` | string | `love`/`strength`/`mission`/`vocation`/`short_term`/`long_term`, indexed |
| `minutes` | integer | Time logged toward it |
| `occurred_at` | datetime | When the work happened, indexed |
| `created_at` | datetime | UTC |
## Extraction Details
`DiscoveryExtractor` is intentionally responsible for plumbing, not business
@@ -718,6 +772,14 @@ callback), so the pages hold no tokens of their own.
`.../respond`
- linked from `profile.html` ("Coaching preferences & check-ins")
`dashboard.html` (Phase 4 goal dashboard):
- reads `GET /discovery/integration/work-patterns?days=N` and renders minutes
and share per foundation as bars, with a selectable window
- surfaces foundations with no logged time and asks whether that matches where
the person wants their energy — it observes, it does not prescribe
- linked from `profile.html` ("Where your time goes")
## Configuration
Populate `.env` with at minimum the Anthropic key and the auth-related
+52
View File
@@ -0,0 +1,52 @@
"""add task_mapping (Phase 4 task-to-goal integration)
Revision ID: 006
Revises: 005
Create Date: 2026-06-16
Phase 4: the ImpactFlow core time-tracker posts each logged unit of work here,
mapped to the profile foundation it builds toward. The work-pattern aggregation
rolls these up to feed the coaching reminder engine and the goal dashboard.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"task_mapping",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"user_id",
sa.String(),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("external_task_id", sa.String(), nullable=False),
sa.Column("task_label", sa.String(), nullable=True),
sa.Column("foundation", sa.String(), nullable=False),
sa.Column("minutes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("occurred_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_task_mapping_user_id", "task_mapping", ["user_id"])
op.create_index(
"ix_task_mapping_foundation", "task_mapping", ["foundation"]
)
op.create_index(
"ix_task_mapping_occurred_at", "task_mapping", ["occurred_at"]
)
def downgrade() -> None:
op.drop_index("ix_task_mapping_occurred_at", table_name="task_mapping")
op.drop_index("ix_task_mapping_foundation", table_name="task_mapping")
op.drop_index("ix_task_mapping_user_id", table_name="task_mapping")
op.drop_table("task_mapping")
+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;
}
+26 -1
View File
@@ -45,10 +45,13 @@ async def _seed_profile(locked: bool = False, triad: str = "gut") -> str:
class FakeCheckinCoach:
body = 'You said you want to "run a pilot welding cohort." Does that still feel true?'
last_work_patterns = "unset"
def __init__(self, api_key=None, model=None):
pass
async def generate(self, profile, prefs):
async def generate(self, profile, prefs, work_patterns=None):
FakeCheckinCoach.last_work_patterns = work_patterns
return FakeCheckinCoach.body
@@ -181,6 +184,28 @@ async def test_run_batch_skips_unlocked_profile(app_client):
assert r["considered"] == 0
async def test_checkin_receives_recent_work_patterns(app_client):
"""Phase 4 wiring: a generated check-in is fed the recent work-pattern
summary so the coach can reflect where time has gone."""
await _seed_profile()
await app_client.post(
"/discovery/integration/task-mappings",
headers=API_KEY,
json={"external_task_id": "t1", "foundation": "short_term", "minutes": 90},
)
FakeCheckinCoach.last_work_patterns = "unset"
await app_client.post("/discovery/coaching/checkins", headers=API_KEY)
assert FakeCheckinCoach.last_work_patterns is not None
assert "last 14 days" in FakeCheckinCoach.last_work_patterns
async def test_checkin_without_work_patterns_passes_none(app_client):
await _seed_profile()
FakeCheckinCoach.last_work_patterns = "unset"
await app_client.post("/discovery/coaching/checkins", headers=API_KEY)
assert FakeCheckinCoach.last_work_patterns is None
async def test_due_logic_unit():
from app.routers.coaching import _is_due
+57
View File
@@ -0,0 +1,57 @@
"""Unit tests for the pure work-pattern aggregator."""
from datetime import datetime, timezone
from app.services.foundations import FOUNDATIONS, rollup, work_pattern_text
def _dt(day):
return datetime(2026, 6, day, tzinfo=timezone.utc)
def test_rollup_sums_minutes_and_counts_per_foundation():
entries = [
{"foundation": "short_term", "minutes": 120, "occurred_at": _dt(10)},
{"foundation": "short_term", "minutes": 60, "occurred_at": _dt(12)},
{"foundation": "vocation", "minutes": 60, "occurred_at": _dt(11)},
]
out = rollup(entries)
assert out["total_minutes"] == 240
top = out["by_foundation"][0]
assert top["foundation"] == "short_term"
assert top["minutes"] == 180
assert top["task_count"] == 2
assert top["last_at"] == _dt(12)
assert round(top["share"], 2) == 0.75
def test_rollup_covers_all_foundations_and_lists_neglected():
out = rollup([{"foundation": "love", "minutes": 30, "occurred_at": _dt(9)}])
assert len(out["by_foundation"]) == len(FOUNDATIONS)
# Every foundation except 'love' has zero minutes.
assert set(out["neglected"]) == set(FOUNDATIONS) - {"love"}
def test_rollup_empty_is_zero_and_all_neglected():
out = rollup([])
assert out["total_minutes"] == 0
assert all(f["minutes"] == 0 for f in out["by_foundation"])
assert set(out["neglected"]) == set(FOUNDATIONS)
def test_rollup_ignores_unknown_foundation():
out = rollup([{"foundation": "nonsense", "minutes": 99, "occurred_at": _dt(9)}])
assert out["total_minutes"] == 0
def test_work_pattern_text_summarizes_activity():
out = rollup([
{"foundation": "short_term", "minutes": 100, "occurred_at": _dt(10)},
])
text = work_pattern_text(out, 14)
assert "last 14 days" in text
assert "Near-term goals" in text
assert "Nothing was logged toward" in text
def test_work_pattern_text_none_when_no_activity():
assert work_pattern_text(rollup([]), 14) is None
+143
View File
@@ -0,0 +1,143 @@
"""Tests for the Phase 4 task-to-goal integration endpoints."""
import uuid
from datetime import datetime, timedelta, timezone
API_KEY = {"X-API-Key": "test-api-key"}
async def _seed_profile() -> str:
from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
from app.database import AsyncSessionLocal
from app.models import DiscoveryConversation, DiscoveryProfile
async with AsyncSessionLocal() as db:
await ensure_api_key_admin(db)
conv = DiscoveryConversation(
id=str(uuid.uuid4()),
user_id=API_KEY_ADMIN_ID,
started_at=datetime.now(timezone.utc),
)
db.add(conv)
await db.commit()
profile = DiscoveryProfile(
id=str(uuid.uuid4()),
user_id=API_KEY_ADMIN_ID,
conversation_id=conv.id,
generated_at=datetime.now(timezone.utc),
triad="gut",
short_term_goals="run a pilot welding cohort",
long_term_goals="a statewide trades outfit",
)
db.add(profile)
await db.commit()
return profile.id
async def test_foundations_lists_six_with_profile_text(app_client):
await _seed_profile()
r = await app_client.get(
"/discovery/integration/foundations", headers=API_KEY
)
assert r.status_code == 200
items = r.json()
assert len(items) == 6
keys = {i["key"] for i in items}
assert keys == {"love", "strength", "mission", "vocation", "short_term", "long_term"}
short = next(i for i in items if i["key"] == "short_term")
assert short["text"] == "run a pilot welding cohort"
async def test_foundations_without_profile_404(app_client):
r = await app_client.get(
"/discovery/integration/foundations", headers=API_KEY
)
assert r.status_code == 404
async def test_create_task_mapping(app_client):
await _seed_profile()
r = await app_client.post(
"/discovery/integration/task-mappings",
headers=API_KEY,
json={
"external_task_id": "task-123",
"foundation": "short_term",
"minutes": 90,
"task_label": "weld practice",
},
)
assert r.status_code == 200
body = r.json()
assert body["foundation"] == "short_term"
assert body["minutes"] == 90
assert body["external_task_id"] == "task-123"
async def test_invalid_foundation_rejected(app_client):
await _seed_profile()
r = await app_client.post(
"/discovery/integration/task-mappings",
headers=API_KEY,
json={"external_task_id": "t", "foundation": "vibes", "minutes": 10},
)
assert r.status_code == 400
async def test_negative_minutes_rejected(app_client):
await _seed_profile()
r = await app_client.post(
"/discovery/integration/task-mappings",
headers=API_KEY,
json={"external_task_id": "t", "foundation": "love", "minutes": -5},
)
assert r.status_code == 400
async def _post_mapping(app_client, foundation, minutes, occurred_at=None):
payload = {
"external_task_id": str(uuid.uuid4()),
"foundation": foundation,
"minutes": minutes,
}
if occurred_at:
payload["occurred_at"] = occurred_at
return await app_client.post(
"/discovery/integration/task-mappings", headers=API_KEY, json=payload
)
async def test_work_patterns_aggregate(app_client):
await _seed_profile()
await _post_mapping(app_client, "short_term", 120)
await _post_mapping(app_client, "short_term", 60)
await _post_mapping(app_client, "vocation", 60)
r = await app_client.get(
"/discovery/integration/work-patterns?days=30", headers=API_KEY
)
assert r.status_code == 200
data = r.json()
assert data["total_minutes"] == 240
top = data["by_foundation"][0]
assert top["foundation"] == "short_term"
assert top["minutes"] == 180
assert "long_term" in data["neglected"]
assert "mission" in data["neglected"]
async def test_work_patterns_window_excludes_old(app_client):
await _seed_profile()
old = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
await _post_mapping(app_client, "love", 100, occurred_at=old)
await _post_mapping(app_client, "strength", 50) # recent
data = (await app_client.get(
"/discovery/integration/work-patterns?days=7", headers=API_KEY
)).json()
assert data["total_minutes"] == 50 # only the recent one
assert "love" in data["neglected"]
async def test_task_mappings_requires_auth(app_client):
r = await app_client.get("/discovery/integration/work-patterns")
assert r.status_code == 401
+13
View File
@@ -62,6 +62,19 @@ def test_profile_page_links_to_coaching():
assert "/static/coaching.html" in html
def test_dashboard_page_uses_work_patterns_endpoint():
"""Phase 4 dashboard reads work patterns via authedFetch."""
html = Path("app/static/dashboard.html").read_text(encoding="utf-8")
assert "/discovery/integration/work-patterns" in html
assert "authedFetch" in html
assert "user_id" not in html
def test_profile_page_links_to_dashboard():
html = Path("app/static/profile.html").read_text(encoding="utf-8")
assert "/static/dashboard.html" in html
def test_auth_helper_sends_credentials_and_refreshes():
js = Path("app/static/auth.js").read_text(encoding="utf-8")