mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:20:36 +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:
@@ -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
|
coaching preferences (auto-derived from their profile) and receive periodic
|
||||||
check-ins that quote their own words and ask if their direction still holds.
|
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.
|
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
|
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
|
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/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/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/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/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
|
||||||
| `app/static/style.css` | Shared UI styling |
|
| `app/static/style.css` | Shared UI styling |
|
||||||
| `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering |
|
| `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/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/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/001_initial.py` | Initial database schema migration |
|
||||||
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
|
| `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/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/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/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/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_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_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_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_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_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) |
|
| `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
|
cadence is due. Eligible = coaching cadence not `off` and an affirmed
|
||||||
(locked) profile; due = no prior check-in or the cadence interval has elapsed.
|
(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
|
## API Reference
|
||||||
|
|
||||||
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
|
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 |
|
| `POST` | `/discovery/coaching/checkins` | yes | Generate a check-in now |
|
||||||
| `PUT` | `/discovery/coaching/checkins/{id}/respond` | yes | Record "is your direction still valid?" |
|
| `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 |
|
| `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
|
## 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 |
|
| `response_note` | text nullable | Optional note with their response |
|
||||||
| `acknowledged_at` | datetime nullable | When they responded |
|
| `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
|
## Extraction Details
|
||||||
|
|
||||||
`DiscoveryExtractor` is intentionally responsible for plumbing, not business
|
`DiscoveryExtractor` is intentionally responsible for plumbing, not business
|
||||||
@@ -718,6 +772,14 @@ callback), so the pages hold no tokens of their own.
|
|||||||
`.../respond`
|
`.../respond`
|
||||||
- linked from `profile.html` ("Coaching preferences & check-ins")
|
- 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
|
## Configuration
|
||||||
|
|
||||||
Populate `.env` with at minimum the Anthropic key and the auth-related
|
Populate `.env` with at minimum the Anthropic key and the auth-related
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -16,6 +16,7 @@ from app.routers import (
|
|||||||
auth as auth_router,
|
auth as auth_router,
|
||||||
coaching,
|
coaching,
|
||||||
discovery,
|
discovery,
|
||||||
|
integration,
|
||||||
)
|
)
|
||||||
from app.routers.activity import prune_old_activity
|
from app.routers.activity import prune_old_activity
|
||||||
from app.tracking import ActivityTrackingMiddleware
|
from app.tracking import ActivityTrackingMiddleware
|
||||||
@@ -70,6 +71,7 @@ app.include_router(auth_router.router)
|
|||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
app.include_router(discovery.router)
|
app.include_router(discovery.router)
|
||||||
app.include_router(coaching.router)
|
app.include_router(coaching.router)
|
||||||
|
app.include_router(integration.router)
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -246,3 +246,27 @@ class CoachingCheckin(Base):
|
|||||||
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
|
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime, nullable=True
|
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
@@ -27,6 +27,11 @@ from app.services.coaching import (
|
|||||||
CheckinError,
|
CheckinError,
|
||||||
generate_preferences,
|
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"])
|
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
|
||||||
|
|
||||||
@@ -206,8 +211,13 @@ async def _generate_checkin(
|
|||||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||||
coach = CheckinCoach(api_key=api_key, model=model)
|
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(
|
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(
|
checkin = CoachingCheckin(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -150,6 +150,50 @@ class RunCheckinsResponse(BaseModel):
|
|||||||
generated: int
|
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):
|
class ConversationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|||||||
@@ -97,8 +97,12 @@ THE PERSON'S OWN WORDS (their profile):
|
|||||||
HOW THEY WANT TO BE COACHED:
|
HOW THEY WANT TO BE COACHED:
|
||||||
{prefs}
|
{prefs}
|
||||||
|
|
||||||
|
RECENT WORK PATTERNS (from their time tracker, may be empty):
|
||||||
|
{work_patterns}
|
||||||
|
|
||||||
YOUR TASK:
|
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.
|
- 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.
|
- 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):
|
ABSOLUTE RULES (mirror, not compass):
|
||||||
@@ -140,12 +144,20 @@ class CheckinCoach:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def generate(
|
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:
|
) -> 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(
|
system = SYSTEM_PROMPT.format(
|
||||||
profile=self._profile_block(profile),
|
profile=self._profile_block(profile),
|
||||||
prefs=self._prefs_block(prefs),
|
prefs=self._prefs_block(prefs),
|
||||||
|
work_patterns=work_patterns or "(no recent work logged)",
|
||||||
style=prefs.get("coaching_style", "warm"),
|
style=prefs.get("coaching_style", "warm"),
|
||||||
prefer_questions=prefs.get("prefer_questions_over_directives", True),
|
prefer_questions=prefs.get("prefer_questions_over_directives", True),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -145,6 +145,9 @@
|
|||||||
const coachingLink =
|
const coachingLink =
|
||||||
`<p class="back-link" style="text-align:center;margin-top:10px">
|
`<p class="back-link" style="text-align:center;margin-top:10px">
|
||||||
<a href="/static/coaching.html">Coaching preferences & check-ins →</a>
|
<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>`;
|
</p>`;
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
|
|||||||
@@ -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 {
|
.back-link {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -45,10 +45,13 @@ async def _seed_profile(locked: bool = False, triad: str = "gut") -> str:
|
|||||||
class FakeCheckinCoach:
|
class FakeCheckinCoach:
|
||||||
body = 'You said you want to "run a pilot welding cohort." Does that still feel true?'
|
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):
|
def __init__(self, api_key=None, model=None):
|
||||||
pass
|
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
|
return FakeCheckinCoach.body
|
||||||
|
|
||||||
|
|
||||||
@@ -181,6 +184,28 @@ async def test_run_batch_skips_unlocked_profile(app_client):
|
|||||||
assert r["considered"] == 0
|
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():
|
async def test_due_logic_unit():
|
||||||
from app.routers.coaching import _is_due
|
from app.routers.coaching import _is_due
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -62,6 +62,19 @@ def test_profile_page_links_to_coaching():
|
|||||||
assert "/static/coaching.html" in html
|
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():
|
def test_auth_helper_sends_credentials_and_refreshes():
|
||||||
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user