mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:00:35 +00:00
50453901b3
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>
279 lines
9.3 KiB
HTML
279 lines
9.3 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>ImpactFlow — Your Profile</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 · Your Direction</div>
|
||
<div id="content">
|
||
<p>Loading your profile…</p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Plain-language description of each enneagram triad. We never show the
|
||
// type number on this page — only the pattern.
|
||
const TRIAD_INFO = {
|
||
gut: {
|
||
label: "You lead with instinct and will",
|
||
text:
|
||
"You move through the world from your gut. You sense what's right before you can fully explain it, and you're driven to act, to protect what matters, and to set things right. Autonomy and integrity are non-negotiable for you, and you'd rather meet a problem head-on than wait for permission.",
|
||
},
|
||
heart: {
|
||
label: "You lead with feeling and connection",
|
||
text:
|
||
"You navigate through emotion and relationship. You're finely attuned to how things land — for you and for the people around you — and you care deeply about being genuinely seen for who you are. Your energy comes alive in connection, recognition, and making others feel that they matter.",
|
||
},
|
||
head: {
|
||
label: "You lead with thought and perception",
|
||
text:
|
||
"You meet the world through your mind. You like to understand before you commit — gathering information, mapping possibilities, and anticipating what's ahead so you can feel prepared and secure. Your gift is seeing patterns and thinking your way toward clarity others miss.",
|
||
},
|
||
};
|
||
|
||
const IKIGAI = [
|
||
{ key: "love_summary", title: "What you love", icon: "♥" },
|
||
{ key: "strength_summary", title: "What you're good at", icon: "★" },
|
||
{ key: "mission_summary", title: "What the world needs", icon: "◆" },
|
||
{ key: "vocation_summary", title: "What you can be paid for", icon: "$" },
|
||
];
|
||
|
||
function confClass(level) {
|
||
if (level === "high") return "high";
|
||
if (level === "medium") return "medium";
|
||
return "low";
|
||
}
|
||
|
||
function dot(level) {
|
||
const c = confClass(level);
|
||
const title = `Confidence: ${level || "low"}`;
|
||
return `<span class="dot ${c}" title="${title}"></span>`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
if (s == null) return "";
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
// The editable prose. Order/labels mirror the read view. The AI's
|
||
// structural read (triad/type/wing/variant) is shown but not edited here.
|
||
const GOAL_FIELDS = [
|
||
{ key: "short_term_goals", title: "Next 6–12 months" },
|
||
{ key: "long_term_goals", title: "Next 3–5 years" },
|
||
];
|
||
|
||
const content = document.getElementById("content");
|
||
let currentProfile = null;
|
||
|
||
async function load() {
|
||
try {
|
||
const res = await authedFetch("/discovery/profile/me");
|
||
if (!res.ok) throw new Error("Profile not found");
|
||
currentProfile = await res.json();
|
||
} catch (err) {
|
||
content.innerHTML = `<div class="error-box">Could not load your profile: ${escapeHtml(
|
||
err.message
|
||
)}</div>`;
|
||
return;
|
||
}
|
||
renderRead();
|
||
}
|
||
|
||
function renderRead() {
|
||
const profile = currentProfile;
|
||
const conf = profile.confidence || {};
|
||
const triad = TRIAD_INFO[profile.triad] || TRIAD_INFO.gut;
|
||
|
||
const ikigaiCards = IKIGAI.map(
|
||
(item) => `
|
||
<div class="card">
|
||
<h3>${dot(conf.ikigai)} ${item.icon} ${item.title}</h3>
|
||
<p>${escapeHtml(profile[item.key]) || "—"}</p>
|
||
</div>`
|
||
).join("");
|
||
|
||
const locked = profile.locked;
|
||
|
||
// Goals are the person's own articulated direction. Only show the
|
||
// section if at least one horizon has content.
|
||
const goals = GOAL_FIELDS.filter(
|
||
(g) => profile[g.key] && profile[g.key].trim()
|
||
);
|
||
const goalsBlock = goals.length
|
||
? `
|
||
<p class="section-label">Where you're headed</p>
|
||
<div class="ikigai-grid">${goals
|
||
.map(
|
||
(g) => `
|
||
<div class="card">
|
||
<h3>→ ${g.title}</h3>
|
||
<p>${escapeHtml(profile[g.key])}</p>
|
||
</div>`
|
||
)
|
||
.join("")}</div>
|
||
`
|
||
: "";
|
||
|
||
// Unlocked profiles can be edited and then affirmed; locked profiles are
|
||
// final and show neither control as actionable.
|
||
const actions = locked
|
||
? `<div class="confirm-row">
|
||
<button class="btn-primary confirmed" disabled>Profile confirmed ✓</button>
|
||
</div>`
|
||
: `<div class="nav">
|
||
<button class="btn-ghost" id="editBtn">Edit my words</button>
|
||
<button class="btn-primary" id="confirmBtn">This is me</button>
|
||
</div>
|
||
<p class="back-link" style="text-align:center;margin-top:18px">
|
||
<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
|
||
)}</div>
|
||
|
||
<p class="section-label">Where your four circles meet</p>
|
||
<div class="ikigai-grid">${ikigaiCards}</div>
|
||
|
||
<div class="triad-block">
|
||
<h2>${dot(conf.triad)} ${triad.label}</h2>
|
||
<p>${triad.text}</p>
|
||
</div>
|
||
|
||
${goalsBlock}
|
||
${actions}
|
||
${coachingLink}
|
||
`;
|
||
|
||
if (locked) return;
|
||
|
||
document
|
||
.getElementById("editBtn")
|
||
.addEventListener("click", renderEdit);
|
||
|
||
const btn = document.getElementById("confirmBtn");
|
||
btn.addEventListener("click", async () => {
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await authedFetch("/discovery/profile/me/confirm", {
|
||
method: "PUT",
|
||
});
|
||
if (!res.ok) throw new Error("confirm failed");
|
||
currentProfile.locked = true;
|
||
renderRead();
|
||
} catch (err) {
|
||
btn.disabled = false;
|
||
btn.textContent = "Try again";
|
||
}
|
||
});
|
||
}
|
||
|
||
function editField(key, label, value) {
|
||
return `
|
||
<div class="edit-field">
|
||
<label for="edit_${key}">${label}</label>
|
||
<textarea class="edit" id="edit_${key}">${escapeHtml(
|
||
value
|
||
)}</textarea>
|
||
</div>`;
|
||
}
|
||
|
||
function renderEdit() {
|
||
const profile = currentProfile;
|
||
|
||
const ikigaiFields = IKIGAI.map((item) =>
|
||
editField(item.key, `${item.icon} ${item.title}`, profile[item.key])
|
||
).join("");
|
||
const goalFields = GOAL_FIELDS.map((g) =>
|
||
editField(g.key, `→ ${g.title}`, profile[g.key])
|
||
).join("");
|
||
|
||
content.innerHTML = `
|
||
<p class="section-label">Edit your words</p>
|
||
<p class="edit-help">These are your words to own. Revise anything that
|
||
doesn't sound like you, then save.</p>
|
||
|
||
${editField(
|
||
"overlap_narrative",
|
||
"Where it all comes together",
|
||
profile.overlap_narrative
|
||
)}
|
||
|
||
<div class="ikigai-grid">${ikigaiFields}</div>
|
||
<div class="ikigai-grid">${goalFields}</div>
|
||
|
||
<div id="editError"></div>
|
||
<div class="nav">
|
||
<button class="btn-ghost" id="cancelBtn">Cancel</button>
|
||
<button class="btn-primary" id="saveBtn">Save changes</button>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById("cancelBtn").addEventListener("click", renderRead);
|
||
document.getElementById("saveBtn").addEventListener("click", saveEdits);
|
||
}
|
||
|
||
const EDITABLE_KEYS = [
|
||
"overlap_narrative",
|
||
...IKIGAI.map((i) => i.key),
|
||
...GOAL_FIELDS.map((g) => g.key),
|
||
];
|
||
|
||
async function saveEdits() {
|
||
const saveBtn = document.getElementById("saveBtn");
|
||
saveBtn.disabled = true;
|
||
|
||
const payload = {};
|
||
EDITABLE_KEYS.forEach((key) => {
|
||
payload[key] = document.getElementById(`edit_${key}`).value;
|
||
});
|
||
|
||
try {
|
||
const res = await authedFetch("/discovery/profile/me", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!res.ok) {
|
||
const detail = await res
|
||
.json()
|
||
.catch(() => ({ detail: "Could not save changes" }));
|
||
throw new Error(detail.detail || "Could not save changes");
|
||
}
|
||
currentProfile = await res.json();
|
||
renderRead();
|
||
} catch (err) {
|
||
saveBtn.disabled = false;
|
||
document.getElementById(
|
||
"editError"
|
||
).innerHTML = `<div class="error-box">${escapeHtml(
|
||
err.message
|
||
)}</div>`;
|
||
}
|
||
}
|
||
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>
|