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