diff --git a/backend/server.py b/backend/server.py
index b13a52b..ba64aa2 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -28,7 +28,7 @@
import secrets
import httpx
from pydantic import BaseModel, Field
-from typing import List, Optional, Literal
+from typing import Any, List, Optional, Literal
from datetime import datetime, timezone, timedelta
from llm_provider import run_llm, run_llm_cheap
@@ -148,7 +148,7 @@ async def _check_diagnosis_gate(user_id: str, idea_id: str):
{"idea_id": idea_id, "user_id": user_id}, {"_id": 0}, sort=[("created_at", -1)]
)
if latest_diag:
- actions = latest_diag.get("this_week_actions", [])
+ actions: List[dict[str, Any]] = list(latest_diag.get("this_week_actions") or [])
if not any(a.get("done") for a in actions):
raise HTTPException(
status_code=400,
@@ -367,6 +367,13 @@ class CheckinCreate(BaseModel):
blockers: str = ""
+class CheckinUpdate(BaseModel):
+ actions_completed: List[str] = []
+ what_changed: str = ""
+ new_learnings: str = ""
+ blockers: str = ""
+
+
class Action(BaseModel):
action_id: str
title: str
@@ -404,10 +411,10 @@ class Checkin(BaseModel):
checkin_id: str
idea_id: str
user_id: str
- actions_completed: List[str]
what_changed: str
new_learnings: str
blockers: str
+ diagnosis_id: Optional[str] = None
delta_summary: str = "" # AI-generated "what changed since last week"
created_at: datetime
@@ -484,6 +491,26 @@ def _serialize_timeline_doc(doc: Optional[dict]) -> Optional[dict]:
)
+def _serialize_checkin(doc: dict) -> dict:
+ v = doc.get("created_at")
+ if isinstance(v, str):
+ try:
+ doc["created_at"] = datetime.fromisoformat(v)
+ except Exception:
+ pass
+ return _add_ist_fields(doc, ["created_at"])
+
+
+def _build_action_snapshot(
+ diag: Optional[dict[str, Any]], completed_action_ids: List[str]
+) -> List[dict[str, Any]]:
+ actions: List[dict[str, Any]] = []
+ completed = set(completed_action_ids or [])
+ for action in (diag or {}).get("this_week_actions", []):
+ actions.append({**action, "done": action.get("action_id") in completed})
+ return actions
+
+
@api.post("/ideas")
async def create_idea(
payload: IdeaCreate,
@@ -710,7 +737,11 @@ async def _parse_or_repair_json(raw: str, session_id: str) -> dict:
def _build_idea_brief(
- user: User, idea: dict, prior_diag: Optional[dict], last_checkin: Optional[dict]
+ user: User,
+ idea: dict,
+ prior_diag: Optional[dict],
+ last_checkin: Optional[dict],
+ last_checkin_diag: Optional[dict],
) -> str:
lines = [
f"FOUNDER: {user.name}",
@@ -733,10 +764,18 @@ def _build_idea_brief(
f"- Previous key insight: {prior_diag.get('key_insight')}",
]
if last_checkin:
+ completed_actions = []
+ pending_actions = []
+ for action in (last_checkin_diag or {}).get("this_week_actions") or []:
+ if action.get("done"):
+ completed_actions.append(action.get("title"))
+ else:
+ pending_actions.append(action.get("title"))
lines += [
"",
"LAST CHECK-IN:",
- f"- Actions completed: {last_checkin.get('actions_completed')}",
+ f"- Actions completed: {completed_actions or '(none)'}",
+ f"- Actions not completed: {pending_actions or '(none)'}",
f"- What changed: {last_checkin.get('what_changed')}",
f"- New learnings: {last_checkin.get('new_learnings')}",
f"- Blockers: {last_checkin.get('blockers')}",
@@ -815,7 +854,17 @@ async def _run_diagnosis_job(job_id: str, idea_id: str, user_id: str):
last_checkin = await db.checkins.find_one(
{"idea_id": idea_id}, {"_id": 0}, sort=[("created_at", -1)]
)
- brief = _build_idea_brief(user, idea, prior, last_checkin)
+ last_checkin_diag = None
+ if last_checkin and last_checkin.get("diagnosis_id"):
+ last_checkin_diag = await db.diagnoses.find_one(
+ {
+ "idea_id": idea_id,
+ "user_id": user_id,
+ "diagnosis_id": last_checkin["diagnosis_id"],
+ },
+ {"_id": 0},
+ )
+ brief = _build_idea_brief(user, idea, prior, last_checkin, last_checkin_diag)
parsed = await asyncio.wait_for(
_run_diagnosis_llm(brief, f"diag_{idea_id}_{uuid.uuid4().hex[:8]}"),
@@ -910,7 +959,7 @@ async def toggle_action(
)
if not diag:
raise HTTPException(404, "No diagnosis")
- actions = diag.get("this_week_actions", [])
+ actions: List[dict[str, Any]] = list(diag.get("this_week_actions") or [])
for a in actions:
if a.get("action_id") == action_id:
a["done"] = not a.get("done", False)
@@ -957,16 +1006,27 @@ async def create_checkin(
streak = 1
checkin_id = f"cki_{uuid.uuid4().hex[:12]}"
+ # find the most recent diagnosis that does NOT yet have a linked checkin
+ latest_diag = None
+ async for d in db.diagnoses.find({"idea_id": idea_id}).sort("created_at", -1):
+ # check if any checkin already links to this diagnosis
+ exists = await db.checkins.find_one(
+ {"diagnosis_id": d.get("diagnosis_id")}, {"_id": 1}
+ )
+ if not exists:
+ latest_diag = d
+ break
doc = {
"checkin_id": checkin_id,
"idea_id": idea_id,
"user_id": user.user_id,
- "actions_completed": payload.actions_completed,
"what_changed": payload.what_changed,
"new_learnings": payload.new_learnings,
"blockers": payload.blockers,
"delta_summary": "",
"created_at": now.isoformat(),
+ # keep the check-in record self-contained in the checkins collection
+ "diagnosis_id": latest_diag.get("diagnosis_id") if latest_diag else None,
}
await db.checkins.insert_one({**doc})
await db.ideas.update_one(
@@ -979,21 +1039,153 @@ async def create_checkin(
}
},
)
- # Also mark completed actions as done in latest diagnosis
+ # Also mark completed actions as done in latest diagnosis (if present)
+ if latest_diag:
+ actions = _build_action_snapshot(latest_diag, payload.actions_completed)
+ await db.diagnoses.update_one(
+ {"diagnosis_id": latest_diag["diagnosis_id"]},
+ {"$set": {"this_week_actions": actions}},
+ )
+ return _serialize_timeline_doc({**doc, "streak": streak})
+
+
+@api.get("/ideas/{idea_id}/checkins")
+async def list_checkins(
+ idea_id: str,
+ request: Request,
+ limit: int = 20,
+ before: Optional[str] = None,
+ session_token_cookie: Optional[str] = Cookie(None, alias="session_token"),
+ authorization: Optional[str] = Header(None),
+):
+ user = await get_current_user(request, session_token_cookie, authorization)
+ idea = await db.ideas.find_one(
+ {"idea_id": idea_id, "user_id": user.user_id, "status": {"$ne": "archived"}},
+ {"_id": 0, "idea_id": 1},
+ )
+ if not idea:
+ raise HTTPException(404, "Idea not found")
+
+ limit = max(1, min(limit, 100))
+ query: dict[str, Any] = {"idea_id": idea_id, "user_id": user.user_id}
+ if before:
+ query["created_at"] = {"$lt": before}
+
+ docs = (
+ await db.checkins.find(query, {"_id": 0})
+ .sort("created_at", -1)
+ .to_list(limit + 1)
+ )
+ has_more = len(docs) > limit
+ items = docs[:limit]
+ next_before = items[-1].get("created_at") if has_more and items else None
+
+ enriched_items = []
+ for item in items:
+ enriched = dict(item)
+ if not enriched.get("diagnosis_actions") and item.get("diagnosis_id"):
+ diag = await db.diagnoses.find_one(
+ {"idea_id": idea_id, "user_id": user.user_id, "diagnosis_id": item["diagnosis_id"]},
+ {"_id": 0, "this_week_actions": 1},
+ )
+ enriched["diagnosis_actions"] = diag.get("this_week_actions", []) if diag else []
+ else:
+ enriched["diagnosis_actions"] = enriched.get("diagnosis_actions", [])
+ enriched_items.append(enriched)
+
+ return {
+ "items": [_serialize_checkin(i) for i in enriched_items],
+ "has_more": has_more,
+ "next_before": next_before,
+ }
+
+
+@api.patch("/ideas/{idea_id}/checkins/{checkin_id}")
+async def update_checkin(
+ idea_id: str,
+ checkin_id: str,
+ payload: CheckinUpdate,
+ request: Request,
+ session_token_cookie: Optional[str] = Cookie(None, alias="session_token"),
+ authorization: Optional[str] = Header(None),
+):
+ user = await get_current_user(request, session_token_cookie, authorization)
+ idea = await db.ideas.find_one(
+ {"idea_id": idea_id, "user_id": user.user_id, "status": {"$ne": "archived"}},
+ {"_id": 0, "idea_id": 1},
+ )
+ if not idea:
+ raise HTTPException(404, "Idea not found")
+
+ latest = await db.checkins.find_one(
+ {"idea_id": idea_id, "user_id": user.user_id},
+ {"_id": 0, "checkin_id": 1},
+ sort=[("created_at", -1)],
+ )
+ if not latest:
+ raise HTTPException(404, "Check-in not found")
+ if latest.get("checkin_id") != checkin_id:
+ raise HTTPException(
+ status_code=400,
+ detail="Only the latest check-in can be edited.",
+ )
+
+ existing = await db.checkins.find_one(
+ {"idea_id": idea_id, "user_id": user.user_id, "checkin_id": checkin_id},
+ {"_id": 0},
+ )
+ if not existing:
+ raise HTTPException(404, "Check-in not found")
+
latest_diag = await db.diagnoses.find_one(
- {"idea_id": idea_id}, {"_id": 0}, sort=[("created_at", -1)]
+ {"idea_id": idea_id, "user_id": user.user_id},
+ {"_id": 0, "diagnosis_id": 1, "this_week_actions": 1},
+ sort=[("created_at", -1)],
+ )
+ if existing.get("diagnosis_id") != (latest_diag or {}).get("diagnosis_id"):
+ raise HTTPException(
+ status_code=400,
+ detail="Only the check-in for the latest diagnosis can be edited.",
+ )
+
+ updated = {
+ "what_changed": payload.what_changed,
+ "new_learnings": payload.new_learnings,
+ "blockers": payload.blockers,
+ }
+ linked_diag = None
+ if existing.get("diagnosis_id"):
+ linked_diag = await db.diagnoses.find_one(
+ {
+ "idea_id": idea_id,
+ "user_id": user.user_id,
+ "diagnosis_id": existing["diagnosis_id"],
+ },
+ {"_id": 0, "this_week_actions": 1},
+ )
+ if not linked_diag:
+ linked_diag = await db.diagnoses.find_one(
+ {"idea_id": idea_id, "user_id": user.user_id},
+ {"_id": 0, "this_week_actions": 1},
+ sort=[("created_at", -1)],
+ )
+
+ await db.checkins.update_one(
+ {"checkin_id": checkin_id, "idea_id": idea_id, "user_id": user.user_id},
+ {
+ "$set": updated
+ },
)
+
if latest_diag:
- actions = latest_diag.get("this_week_actions", [])
- completed = set(payload.actions_completed or [])
- for a in actions:
- if a.get("action_id") in completed:
- a["done"] = True
+ actions = _build_action_snapshot(latest_diag, payload.actions_completed)
await db.diagnoses.update_one(
{"diagnosis_id": latest_diag["diagnosis_id"]},
{"$set": {"this_week_actions": actions}},
)
- return _serialize_timeline_doc({**doc, "streak": streak})
+
+ out = {**existing, **updated}
+ return _serialize_checkin(out)
@api.get("/dashboard/summary")
@@ -1168,7 +1360,7 @@ async def build_shared_diagnosis_content(slug: str) -> dict:
"why": a.get("why"),
"effort": a.get("effort"),
}
- for a in diag.get("this_week_actions", [])
+ for a in list(diag.get("this_week_actions") or [])
],
"created_at": diag["created_at"],
"created_at_ist": _to_ist_iso(diag["created_at"]),
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 7d1edb4..a8c360c 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -101,6 +101,19 @@ export async function createCheckin(idea_id, payload) {
return data;
}
+export async function listCheckins(idea_id, params = {}) {
+ const { data } = await api.get(`/ideas/${idea_id}/checkins`, { params });
+ return data;
+}
+
+export async function updateCheckin(idea_id, checkin_id, payload) {
+ const { data } = await api.patch(
+ `/ideas/${idea_id}/checkins/${checkin_id}`,
+ payload,
+ );
+ return data;
+}
+
export async function deleteIdea(idea_id) {
const { data } = await api.delete(`/ideas/${idea_id}`);
return data;
diff --git a/frontend/src/pages/IdeaDetail.checkin.test.jsx b/frontend/src/pages/IdeaDetail.checkin.test.jsx
new file mode 100644
index 0000000..10308a4
--- /dev/null
+++ b/frontend/src/pages/IdeaDetail.checkin.test.jsx
@@ -0,0 +1,115 @@
+// Ensure React's act environment is enabled for concurrent rendering in tests
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+import React from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+
+jest.mock(
+ "react-router-dom",
+ () => ({
+ useNavigate: () => jest.fn(),
+ useParams: () => ({}),
+ useSearchParams: () => [new URLSearchParams(), jest.fn()],
+ }),
+ { virtual: true },
+);
+
+jest.mock(
+ "@/lib/track",
+ () => ({
+ track: jest.fn(),
+ }),
+ { virtual: true },
+);
+
+const { CheckinDialog } = require("./IdeaDetail");
+
+function renderDialog(props) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
{previewRecheck.canRecheck @@ -283,6 +291,8 @@ function CheckinDialog({ : "Your check-in will still be saved. Re-diagnosis unlocks when the re-diagnosis timer does."}
+ {/* Removed prefill/start-mode: new check-ins always start blank */} +{idea.one_liner}
+ {c.what_changed || "-"} +
++ {c.new_learnings || "-"} +
++ {c.blockers || "-"} +
+- {d.key_insight} -
+ {checkinsLoadingMore ? "Loading..." : "Load older check-ins"} ++ {d.key_insight} +
+