mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 08:30:35 +00:00
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:
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user