Phase 3: coaching preferences + weekly check-in engine

Add coaching preferences (auto-derived from the profile, user-overridable) and
a periodic check-in engine that quotes the person's own words and asks whether
their direction still feels valid — mirror, not compass.

- Preferences are deterministic: a documented triad mapping (gut → direct/
  higher-friction, heart → warm/drift-sensitive, head → reflective/question-led)
  produces defaults for the six fields (coaching_frequency, coaching_style,
  misalignment_threshold, friction_tolerance, prefer_questions_over_directives,
  time_of_day_preference). PUT overrides; regenerate re-derives.
- CheckinCoach (app/services/coaching.py): Anthropic-backed; writes a check-in
  that quotes the person's goals back and asks if the direction still holds.
- Endpoints (app/routers/coaching.py): GET/PUT/regenerate preferences;
  GET/POST checkins; respond (records still_valid); admin POST /run is the
  weekly batch (due = cadence elapsed + locked profile), intended for a cron.
- Models + migration 005: coaching_preferences (per user) and coaching_checkin.
- Frontend: coaching.html (preferences form + check-in feed); linked from
  profile.html.

Tests: 68 passing (added deterministic-preference unit tests and coaching
endpoint/batch tests; run in-container). README updated for Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-06-16 21:10:54 -05:00
parent b4d8d17aed
commit 50453901b3
13 changed files with 1317 additions and 1 deletions
+7 -1
View File
@@ -11,7 +11,12 @@ from starlette.middleware.sessions import SessionMiddleware
from app import models # noqa: F401 - register ORM models with Base.metadata
from app.auth import ensure_api_key_admin
from app.database import AsyncSessionLocal, Base, engine
from app.routers import activity, auth as auth_router, discovery
from app.routers import (
activity,
auth as auth_router,
coaching,
discovery,
)
from app.routers.activity import prune_old_activity
from app.tracking import ActivityTrackingMiddleware
@@ -64,6 +69,7 @@ app.add_middleware(
app.include_router(auth_router.router)
app.include_router(activity.router)
app.include_router(discovery.router)
app.include_router(coaching.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+58
View File
@@ -188,3 +188,61 @@ class ProfileRevision(Base):
fields_json: Mapped[str] = mapped_column(Text, nullable=False)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class CoachingPreferences(Base):
"""Phase 3: how this person wants to be coached. Auto-generated from their
Enneagram/Ikigai profile, then overridable. One row per user."""
__tablename__ = "coaching_preferences"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), unique=True, nullable=False, index=True
)
# The profile the defaults were derived from (provenance).
profile_id: Mapped[Optional[str]] = mapped_column(
String, ForeignKey("discovery_profile.id"), nullable=True
)
coaching_frequency: Mapped[str] = mapped_column(String, nullable=False)
coaching_style: Mapped[str] = mapped_column(String, nullable=False)
misalignment_threshold: Mapped[str] = mapped_column(String, nullable=False)
friction_tolerance: Mapped[str] = mapped_column(String, nullable=False)
prefer_questions_over_directives: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
time_of_day_preference: Mapped[str] = mapped_column(String, nullable=False)
# True while still using auto-derived defaults; False once the user edits.
auto_generated: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class CoachingCheckin(Base):
"""Phase 3: a periodic coaching check-in. The body quotes the person's own
words and asks whether their stated direction still feels valid (mirror,
not compass). The person's answer is recorded in still_valid."""
__tablename__ = "coaching_checkin"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
profile_id: Mapped[str] = mapped_column(
String, ForeignKey("discovery_profile.id"), nullable=False
)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, index=True
)
# The person's self-assessment: is their direction still valid?
still_valid: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
response_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
+322
View File
@@ -0,0 +1,322 @@
"""Phase 3 coaching routes: preferences (auto-generated + overridable) and the
weekly check-in engine.
All routes are user-scoped via the dual-auth dependency; the batch ``/run``
trigger is admin-only (a cron calls it weekly).
"""
import os
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.auth import get_current_user, require_admin
from app.database import get_db
from app.models import (
CoachingCheckin,
CoachingPreferences,
DiscoveryProfile,
User,
)
from app.services.coaching import (
ALLOWED,
CheckinCoach,
CheckinError,
generate_preferences,
)
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
# How long between check-ins for each cadence; "off" means never.
_FREQUENCY_DAYS = {"weekly": 7, "biweekly": 14, "monthly": 30, "off": None}
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()
async def _get_preferences(
db: AsyncSession, user_id: str
) -> CoachingPreferences | None:
stmt = select(CoachingPreferences).where(
CoachingPreferences.user_id == user_id
)
return (await db.execute(stmt)).scalars().first()
def _profile_dict(profile: DiscoveryProfile) -> dict:
return {
"triad": profile.triad,
"love_summary": profile.love_summary,
"strength_summary": profile.strength_summary,
"mission_summary": profile.mission_summary,
"vocation_summary": profile.vocation_summary,
"overlap_narrative": profile.overlap_narrative,
"short_term_goals": profile.short_term_goals,
"long_term_goals": profile.long_term_goals,
}
def _prefs_out(p: CoachingPreferences) -> schemas.CoachingPreferencesOut:
return schemas.CoachingPreferencesOut(
coaching_frequency=p.coaching_frequency,
coaching_style=p.coaching_style,
misalignment_threshold=p.misalignment_threshold,
friction_tolerance=p.friction_tolerance,
prefer_questions_over_directives=p.prefer_questions_over_directives,
time_of_day_preference=p.time_of_day_preference,
auto_generated=p.auto_generated,
updated_at=p.updated_at,
)
def _checkin_out(c: CoachingCheckin) -> schemas.CheckinOut:
return schemas.CheckinOut(
id=c.id,
body=c.body,
created_at=c.created_at,
still_valid=c.still_valid,
response_note=c.response_note,
acknowledged_at=c.acknowledged_at,
)
async def _ensure_preferences(
db: AsyncSession, user: User
) -> CoachingPreferences:
"""Return the user's preferences, deriving defaults from their latest
profile on first access. 404 if they have no profile yet."""
prefs = await _get_preferences(db, user.id)
if prefs is not None:
return prefs
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(
status_code=404,
detail="No profile yet; complete self-discovery first.",
)
defaults = generate_preferences(_profile_dict(profile))
now = _now()
prefs = CoachingPreferences(
id=str(uuid.uuid4()),
user_id=user.id,
profile_id=profile.id,
auto_generated=True,
created_at=now,
updated_at=now,
**defaults,
)
db.add(prefs)
await db.commit()
await db.refresh(prefs)
return prefs
@router.get("/preferences", response_model=schemas.CoachingPreferencesOut)
async def get_preferences(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
return _prefs_out(await _ensure_preferences(db, user))
@router.put("/preferences", response_model=schemas.CoachingPreferencesOut)
async def update_preferences(
payload: schemas.CoachingPreferencesUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
prefs = await _ensure_preferences(db, user)
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
for field, value in updates.items():
if field in ALLOWED and value not in ALLOWED[field]:
raise HTTPException(
status_code=400,
detail=f"Invalid {field}: {value!r}. "
f"Allowed: {sorted(ALLOWED[field])}",
)
setattr(prefs, field, value)
prefs.auto_generated = False
prefs.updated_at = _now()
await db.commit()
await db.refresh(prefs)
return _prefs_out(prefs)
@router.post(
"/preferences/regenerate",
response_model=schemas.CoachingPreferencesOut,
)
async def regenerate_preferences(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Re-derive defaults from the latest profile, discarding overrides."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
prefs = await _ensure_preferences(db, user)
for field, value in generate_preferences(_profile_dict(profile)).items():
setattr(prefs, field, value)
prefs.profile_id = profile.id
prefs.auto_generated = True
prefs.updated_at = _now()
await db.commit()
await db.refresh(prefs)
return _prefs_out(prefs)
@router.get("/checkins", response_model=list[schemas.CheckinOut])
async def list_checkins(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
stmt = (
select(CoachingCheckin)
.where(CoachingCheckin.user_id == user.id)
.order_by(CoachingCheckin.created_at.desc())
)
rows = (await db.execute(stmt)).scalars().all()
return [_checkin_out(c) for c in rows]
async def _generate_checkin(
db: AsyncSession, user_id: str, profile: DiscoveryProfile,
prefs: CoachingPreferences,
) -> CoachingCheckin:
"""Generate and persist one check-in. Caller commits."""
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
coach = CheckinCoach(api_key=api_key, model=model)
body = await coach.generate(
_profile_dict(profile), _prefs_out(prefs).model_dump()
)
checkin = CoachingCheckin(
id=str(uuid.uuid4()),
user_id=user_id,
profile_id=profile.id,
body=body,
created_at=_now(),
)
db.add(checkin)
return checkin
@router.post("/checkins", response_model=schemas.CheckinOut)
async def create_checkin(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Generate a check-in now for the current user (on demand)."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
prefs = await _ensure_preferences(db, user)
try:
checkin = await _generate_checkin(db, user.id, profile, prefs)
except CheckinError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
await db.commit()
await db.refresh(checkin)
return _checkin_out(checkin)
@router.put(
"/checkins/{checkin_id}/respond", response_model=schemas.CheckinOut
)
async def respond_to_checkin(
checkin_id: str,
payload: schemas.CheckinRespondRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Record the person's self-assessment: is their direction still valid?"""
checkin = await db.get(CoachingCheckin, checkin_id)
if checkin is None or (
checkin.user_id != user.id and user.role != "admin"
):
raise HTTPException(status_code=404, detail="Check-in not found")
checkin.still_valid = payload.still_valid
checkin.response_note = payload.note or None
checkin.acknowledged_at = _now()
await db.commit()
await db.refresh(checkin)
return _checkin_out(checkin)
async def _last_checkin_at(
db: AsyncSession, user_id: str
) -> datetime | None:
stmt = (
select(CoachingCheckin.created_at)
.where(CoachingCheckin.user_id == user_id)
.order_by(CoachingCheckin.created_at.desc())
)
return (await db.execute(stmt)).scalars().first()
def _is_due(frequency: str, last_at: datetime | None, now: datetime) -> bool:
days = _FREQUENCY_DAYS.get(frequency)
if days is None: # "off" or unknown cadence
return False
if last_at is None:
return True
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=timezone.utc)
return now - last_at >= timedelta(days=days)
@router.post("/run", response_model=schemas.RunCheckinsResponse)
async def run_due_checkins(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
"""The weekly batch job: generate a check-in for every eligible user whose
cadence is due. Eligible = has coaching preferences (cadence != off) and an
affirmed (locked) profile. Intended to be invoked by a weekly cron."""
now = _now()
prefs_rows = (
await db.execute(select(CoachingPreferences))
).scalars().all()
considered = 0
generated = 0
for prefs in prefs_rows:
if _FREQUENCY_DAYS.get(prefs.coaching_frequency) is None:
continue
profile = await _latest_profile(db, prefs.user_id)
if profile is None or not profile.locked:
continue
considered += 1
last_at = await _last_checkin_at(db, prefs.user_id)
if not _is_due(prefs.coaching_frequency, last_at, now):
continue
try:
await _generate_checkin(db, prefs.user_id, profile, prefs)
generated += 1
except CheckinError:
# Skip this user this run; a transient model error shouldn't abort
# the whole batch.
continue
await db.commit()
return schemas.RunCheckinsResponse(
considered=considered, generated=generated
)
+42
View File
@@ -108,6 +108,48 @@ class ProfileRevisionOut(BaseModel):
created_at: datetime
# -- Phase 3: coaching preferences + check-ins -------------------------------
class CoachingPreferencesOut(BaseModel):
coaching_frequency: str
coaching_style: str
misalignment_threshold: str
friction_tolerance: str
prefer_questions_over_directives: bool
time_of_day_preference: str
auto_generated: bool
updated_at: datetime
class CoachingPreferencesUpdate(BaseModel):
coaching_frequency: Optional[str] = None
coaching_style: Optional[str] = None
misalignment_threshold: Optional[str] = None
friction_tolerance: Optional[str] = None
prefer_questions_over_directives: Optional[bool] = None
time_of_day_preference: Optional[str] = None
class CheckinOut(BaseModel):
id: str
body: str
created_at: datetime
still_valid: Optional[bool] = None
response_note: Optional[str] = None
acknowledged_at: Optional[datetime] = None
class CheckinRespondRequest(BaseModel):
still_valid: bool
note: str = ""
class RunCheckinsResponse(BaseModel):
considered: int
generated: int
class ConversationResponse(BaseModel):
id: str
user_id: str
+170
View File
@@ -0,0 +1,170 @@
"""Phase 3 coaching: preference generation + the weekly check-in coach.
Two distinct pieces:
- ``generate_preferences`` is DETERMINISTIC. Coaching preferences are a
structural read of the person's Enneagram centre, so they are derived from a
documented mapping (no LLM, fully testable). The user can override any field.
- ``CheckinCoach`` is the LLM piece. It writes a periodic check-in that quotes
the person's OWN words and asks whether their stated direction still feels
valid. Mirror, not compass: it asks, it never judges or prescribes.
"""
from typing import Any, Dict
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 600
# Allowed values for each preference field (validated at the API boundary).
ALLOWED = {
"coaching_frequency": {"weekly", "biweekly", "monthly", "off"},
"coaching_style": {"direct", "warm", "reflective"},
"misalignment_threshold": {"low", "medium", "high"},
"friction_tolerance": {"low", "medium", "high"},
"time_of_day_preference": {"morning", "afternoon", "evening"},
}
# Per-triad defaults. Rationale:
# gut — acts from instinct; wants it direct, tolerates friction, fewer
# questions, a weekly nudge in the morning.
# heart— navigates by feeling/connection; wants warmth, is sensitive to
# drift (low threshold), prefers questions.
# head — thinks before committing; wants reflective space, low friction
# tolerance, questions over directives, a slower (biweekly) cadence.
_TRIAD_DEFAULTS = {
"gut": {
"coaching_frequency": "weekly",
"coaching_style": "direct",
"misalignment_threshold": "medium",
"friction_tolerance": "high",
"prefer_questions_over_directives": False,
"time_of_day_preference": "morning",
},
"heart": {
"coaching_frequency": "weekly",
"coaching_style": "warm",
"misalignment_threshold": "low",
"friction_tolerance": "medium",
"prefer_questions_over_directives": True,
"time_of_day_preference": "morning",
},
"head": {
"coaching_frequency": "biweekly",
"coaching_style": "reflective",
"misalignment_threshold": "medium",
"friction_tolerance": "low",
"prefer_questions_over_directives": True,
"time_of_day_preference": "evening",
},
}
# Used when the triad is missing/unknown: a gentle, question-led default that
# is consistent with mirror-not-compass.
_FALLBACK_DEFAULTS = {
"coaching_frequency": "weekly",
"coaching_style": "warm",
"misalignment_threshold": "medium",
"friction_tolerance": "medium",
"prefer_questions_over_directives": True,
"time_of_day_preference": "morning",
}
def generate_preferences(profile: Dict[str, Any]) -> Dict[str, Any]:
"""Derive default coaching preferences from a profile's Enneagram centre.
Args:
profile: at least ``{"triad": "gut"|"heart"|"head"|None}``.
Returns:
A dict with all six preference fields.
"""
triad = (profile.get("triad") or "").lower()
return dict(_TRIAD_DEFAULTS.get(triad, _FALLBACK_DEFAULTS))
class CheckinError(Exception):
"""Raised when check-in generation fails (API error or empty output)."""
SYSTEM_PROMPT = """You are an AI coach writing a brief, periodic check-in for a person using a self-discovery tool. You are a MIRROR, never a compass.
THE PERSON'S OWN WORDS (their profile):
{profile}
HOW THEY WANT TO BE COACHED:
{prefs}
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.
- 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):
- NEVER tell them what to do, whether they are on or off track, or what they "should" pursue. You ASK; you do not judge.
- NEVER invent goals or direction they did not state. Only reflect their own words.
- Do not mention Enneagram type numbers.
- Match their preferred style ({style}). If they prefer questions over directives ({prefer_questions}), lead with a question rather than a statement.
- Plain language, second person (you/your). No preamble, no sign-off, no markdown — just the check-in text."""
class CheckinCoach:
"""Generates the text of a single coaching check-in."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
if not api_key:
raise CheckinError(
"ANTHROPIC_API_KEY is not set; cannot generate a check-in."
)
self.model = model
self.client = AsyncAnthropic(api_key=api_key)
@staticmethod
def _profile_block(profile: Dict[str, Any]) -> str:
lines = [
f"- What you love: {profile.get('love_summary') or '(none)'}",
f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}",
f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}",
f"- Near-term goals: {profile.get('short_term_goals') or '(none stated)'}",
f"- Long-term goals: {profile.get('long_term_goals') or '(none stated)'}",
]
return "\n".join(lines)
@staticmethod
def _prefs_block(prefs: Dict[str, Any]) -> str:
return (
f"- style: {prefs.get('coaching_style')}\n"
f"- prefers questions over directives: {prefs.get('prefer_questions_over_directives')}\n"
f"- friction tolerance: {prefs.get('friction_tolerance')}"
)
async def generate(
self, profile: Dict[str, Any], prefs: Dict[str, Any]
) -> str:
"""Produce the check-in body text. Raises CheckinError on failure."""
system = SYSTEM_PROMPT.format(
profile=self._profile_block(profile),
prefs=self._prefs_block(prefs),
style=prefs.get("coaching_style", "warm"),
prefer_questions=prefs.get("prefer_questions_over_directives", True),
)
try:
response = await self.client.messages.create(
model=self.model,
max_tokens=MAX_TOKENS,
system=system,
messages=[
{
"role": "user",
"content": "Write my check-in for this week.",
}
],
)
text = response.content[0].text.strip()
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
raise CheckinError(f"Anthropic API call failed: {exc}") from exc
if not text:
raise CheckinError("Model returned an empty check-in.")
return text
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Coaching</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 · Coaching</div>
<div id="prefs"><p>Loading your preferences…</p></div>
<p class="section-label" style="margin-top:36px">Check-ins</p>
<div id="checkins"><p>Loading…</p></div>
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
</div>
<script>
const SELECTS = {
coaching_frequency: ["weekly", "biweekly", "monthly", "off"],
coaching_style: ["direct", "warm", "reflective"],
misalignment_threshold: ["low", "medium", "high"],
friction_tolerance: ["low", "medium", "high"],
time_of_day_preference: ["morning", "afternoon", "evening"],
};
const LABELS = {
coaching_frequency: "How often should I check in?",
coaching_style: "Coaching style",
misalignment_threshold: "Flag drift when it's…",
friction_tolerance: "Friction tolerance",
time_of_day_preference: "Best time of day",
};
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function selectField(key, value) {
const opts = SELECTS[key]
.map(
(o) =>
`<option value="${o}"${o === value ? " selected" : ""}>${o}</option>`
)
.join("");
return `<div class="edit-field">
<label for="pref_${key}">${LABELS[key]}</label>
<select id="pref_${key}" class="pref-select">${opts}</select>
</div>`;
}
function renderPrefs(p) {
const fields = Object.keys(SELECTS).map((k) => selectField(k, p[k])).join("");
const origin =
p.auto_generated
? "Auto-generated from your profile."
: "Customized by you.";
document.getElementById("prefs").innerHTML = `
<p class="section-label">How you want to be coached</p>
<p class="edit-help">${origin}</p>
<div class="pref-grid">${fields}</div>
<div class="edit-field" style="display:flex;align-items:center;gap:10px">
<input type="checkbox" id="pref_prefer_questions_over_directives" ${
p.prefer_questions_over_directives ? "checked" : ""
} />
<label for="pref_prefer_questions_over_directives" style="margin:0">
Prefer questions over directives
</label>
</div>
<div class="nav">
<button class="btn-ghost" id="regenBtn">Reset to suggested</button>
<button class="btn-primary" id="saveBtn">Save preferences</button>
</div>
<div id="prefMsg"></div>`;
document.getElementById("saveBtn").addEventListener("click", savePrefs);
document.getElementById("regenBtn").addEventListener("click", regenPrefs);
}
function collectPrefs() {
const out = {};
for (const k of Object.keys(SELECTS)) {
out[k] = document.getElementById(`pref_${k}`).value;
}
out.prefer_questions_over_directives = document.getElementById(
"pref_prefer_questions_over_directives"
).checked;
return out;
}
async function savePrefs() {
const btn = document.getElementById("saveBtn");
btn.disabled = true;
try {
const res = await authedFetch("/discovery/coaching/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPrefs()),
});
if (!res.ok) throw new Error("Could not save");
renderPrefs(await res.json());
note("prefMsg", "Saved.");
} catch (e) {
btn.disabled = false;
note("prefMsg", e.message, true);
}
}
async function regenPrefs() {
try {
const res = await authedFetch(
"/discovery/coaching/preferences/regenerate",
{ method: "POST" }
);
if (!res.ok) throw new Error("Could not reset");
renderPrefs(await res.json());
note("prefMsg", "Reset to the profile's suggested defaults.");
} catch (e) {
note("prefMsg", e.message, true);
}
}
function note(where, text, isError) {
const el = document.getElementById(where);
if (el)
el.innerHTML = `<p class="${isError ? "error-box" : "note"}">${escapeHtml(
text
)}</p>`;
}
function renderCheckins(list) {
const items = list.length
? list
.map((c) => {
const answered = c.acknowledged_at;
const status = answered
? `<p class="note">${
c.still_valid
? "You said this still feels true."
: "You said this has shifted."
}${c.response_note ? " — " + escapeHtml(c.response_note) : ""}</p>`
: `<div class="nav" data-id="${c.id}">
<button class="btn-ghost resp" data-v="false">It's shifted</button>
<button class="btn-primary resp" data-v="true">Still feels true</button>
</div>`;
return `<div class="card" style="margin-bottom:16px">
<p>${escapeHtml(c.body)}</p>
${status}
</div>`;
})
.join("")
: `<p class="edit-help">No check-ins yet.</p>`;
document.getElementById("checkins").innerHTML = `
<div class="nav" style="justify-content:flex-end">
<button class="btn-primary" id="genBtn">Check in with me now</button>
</div>
<div id="genMsg"></div>
${items}`;
document.getElementById("genBtn").addEventListener("click", generateNow);
document.querySelectorAll(".resp").forEach((b) =>
b.addEventListener("click", () => respond(b))
);
}
async function generateNow() {
const btn = document.getElementById("genBtn");
btn.disabled = true;
note("genMsg", "Thinking…");
try {
const res = await authedFetch("/discovery/coaching/checkins", {
method: "POST",
});
if (!res.ok) {
const d = await res.json().catch(() => ({ detail: "Failed" }));
throw new Error(d.detail || "Failed");
}
await loadCheckins();
} catch (e) {
btn.disabled = false;
note("genMsg", e.message, true);
}
}
async function respond(btn) {
const id = btn.closest(".nav").getAttribute("data-id");
const stillValid = btn.getAttribute("data-v") === "true";
try {
const res = await authedFetch(
`/discovery/coaching/checkins/${encodeURIComponent(id)}/respond`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ still_valid: stillValid }),
}
);
if (!res.ok) throw new Error("Could not record");
await loadCheckins();
} catch (e) {
note("genMsg", e.message, true);
}
}
async function loadPrefs() {
try {
const res = await authedFetch("/discovery/coaching/preferences");
if (!res.ok) {
const d = await res.json().catch(() => ({ detail: "Failed" }));
throw new Error(d.detail || "Failed");
}
renderPrefs(await res.json());
} catch (e) {
document.getElementById("prefs").innerHTML =
`<div class="error-box">${escapeHtml(e.message)}</div>`;
}
}
async function loadCheckins() {
try {
const res = await authedFetch("/discovery/coaching/checkins");
if (!res.ok) throw new Error("Could not load check-ins");
renderCheckins(await res.json());
} catch (e) {
document.getElementById("checkins").innerHTML =
`<div class="error-box">${escapeHtml(e.message)}</div>`;
}
}
loadPrefs();
loadCheckins();
</script>
</body>
</html>
+6
View File
@@ -142,6 +142,11 @@
<a href="/static/reflect.html">Not quite right? Talk it through with your coach →</a>
</p>`;
const coachingLink =
`<p class="back-link" style="text-align:center;margin-top:10px">
<a href="/static/coaching.html">Coaching preferences & check-ins →</a>
</p>`;
content.innerHTML = `
<div class="profile-narrative">${escapeHtml(
profile.overlap_narrative
@@ -157,6 +162,7 @@
${goalsBlock}
${actions}
${coachingLink}
`;
if (locked) return;
+30
View File
@@ -368,6 +368,36 @@ textarea.edit {
min-height: 90px;
}
.pref-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 8px;
}
.pref-select {
width: 100%;
padding: 11px 14px;
border: 1px solid rgba(13, 27, 42, 0.18);
border-radius: 10px;
background: #fff;
color: var(--navy);
font-family: "DM Sans", system-ui, sans-serif;
font-size: 1rem;
}
.pref-select:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.25);
}
@media (max-width: 560px) {
.pref-grid {
grid-template-columns: 1fr;
}
}
.back-link {
margin-top: 8px;
}