mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 03:40: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
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# -- 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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 =
|
||||
`<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 = `
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user