Loading…
diff --git a/README.md b/README.md index 618df5c..4691fa6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/alembic/versions/006_add_task_mapping.py b/alembic/versions/006_add_task_mapping.py new file mode 100644 index 0000000..0944ae1 --- /dev/null +++ b/alembic/versions/006_add_task_mapping.py @@ -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") diff --git a/app/main.py b/app/main.py index 7d4f96d..9a951f3 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py index 47d13a4..ccf5145 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/routers/coaching.py b/app/routers/coaching.py index fe605cf..037bb44 100644 --- a/app/routers/coaching.py +++ b/app/routers/coaching.py @@ -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()), diff --git a/app/routers/integration.py b/app/routers/integration.py new file mode 100644 index 0000000..ad7f10e --- /dev/null +++ b/app/routers/integration.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index f171711..324b5cc 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/app/services/coaching.py b/app/services/coaching.py index 4772b79..f95634b 100644 --- a/app/services/coaching.py +++ b/app/services/coaching.py @@ -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), ) diff --git a/app/services/foundations.py b/app/services/foundations.py new file mode 100644 index 0000000..bd17150 --- /dev/null +++ b/app/services/foundations.py @@ -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 (6–12mo)", + "long_term": "Long-term goals (3–5yr)", +} + +# 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) diff --git a/app/static/dashboard.html b/app/static/dashboard.html new file mode 100644 index 0000000..667c365 --- /dev/null +++ b/app/static/dashboard.html @@ -0,0 +1,108 @@ + + +
+ + +