Spaces:
Sleeping
Sleeping
Add ChatPlan paste/export loop with check-off and FSE annotations.
Browse files- app/chat_plan.py +353 -0
- app/routers/plan.py +124 -1
- app/schedule_store.py +109 -36
- docs/SCHEDULE.md +12 -1
- frontend/src/api.ts +78 -0
- frontend/src/components/BlockSheet.tsx +124 -73
- frontend/src/pages/Home.tsx +11 -2
- frontend/src/pages/Plan.tsx +303 -11
- static/assets/index-DYDM6L-p.js +0 -0
- static/assets/index-DYDM6L-p.js.map +0 -0
- static/assets/index-kJxQGPW0.js +0 -0
- static/assets/index-kJxQGPW0.js.map +0 -0
- static/index.html +1 -1
- static/sw.js +1 -1
- tests/test_chat_plan.py +198 -0
app/chat_plan.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Map ChatPlan JSON ↔ DayPlanPut / BlockFeedback for external AI chat loop."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Literal
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
|
| 8 |
+
from app.schedule_math import minutes_between, overlaps, validate_blocks
|
| 9 |
+
from app.schedule_store import (
|
| 10 |
+
BlockFeedback,
|
| 11 |
+
DayPlan,
|
| 12 |
+
DayPlanPut,
|
| 13 |
+
DayReview,
|
| 14 |
+
ScheduledBlock,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
KIND_SET = {
|
| 18 |
+
"earn_ship",
|
| 19 |
+
"admin_spain",
|
| 20 |
+
"body_care",
|
| 21 |
+
"move_out",
|
| 22 |
+
"boundary",
|
| 23 |
+
"food_out",
|
| 24 |
+
"stabilize",
|
| 25 |
+
"explore",
|
| 26 |
+
"restore_fun",
|
| 27 |
+
"sleep_window",
|
| 28 |
+
"other",
|
| 29 |
+
}
|
| 30 |
+
INTENT_SET = {"duty", "explore", "restore_fun", "measure"}
|
| 31 |
+
PRIORITY_SET = {"P0", "P1", "P2"}
|
| 32 |
+
STATUS_SET = {"planned", "done", "partial", "skipped", "moved", "cancelled"}
|
| 33 |
+
DID_SET = {"done", "partial", "skipped"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ChatPlanError(Exception):
|
| 37 |
+
"""Validation failure with structured messages."""
|
| 38 |
+
|
| 39 |
+
def __init__(self, errors: list[str]) -> None:
|
| 40 |
+
self.errors = errors
|
| 41 |
+
Exception.__init__(self, "; ".join(errors))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _as_list(value: Any) -> list[str]:
|
| 45 |
+
if value is None:
|
| 46 |
+
return []
|
| 47 |
+
if isinstance(value, list):
|
| 48 |
+
return [str(x).strip() for x in value if str(x).strip()]
|
| 49 |
+
text = str(value).strip()
|
| 50 |
+
return [text] if text else []
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _coerce_kind(raw: Any, notes: str) -> tuple[str, str]:
|
| 54 |
+
kind = str(raw or "other").strip() or "other"
|
| 55 |
+
if kind in KIND_SET:
|
| 56 |
+
return kind, notes
|
| 57 |
+
prefix = f"[kind:{kind}]"
|
| 58 |
+
merged = f"{prefix} {notes}".strip() if notes else prefix
|
| 59 |
+
return "other", merged
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _status_from_review(status: str, review: dict[str, Any] | None) -> str:
|
| 63 |
+
if not review:
|
| 64 |
+
return status if status in STATUS_SET else "planned"
|
| 65 |
+
did = review.get("did")
|
| 66 |
+
if did in DID_SET and status in ("planned", "", None):
|
| 67 |
+
return str(did)
|
| 68 |
+
return status if status in STATUS_SET else "planned"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def is_legacy_day_plan_put(payload: dict[str, Any]) -> bool:
|
| 72 |
+
"""True when body looks like DayPlanPut (agent format), not ChatPlan."""
|
| 73 |
+
|
| 74 |
+
blocks = payload.get("blocks")
|
| 75 |
+
if not isinstance(blocks, list) or not blocks:
|
| 76 |
+
return False
|
| 77 |
+
first = blocks[0] if isinstance(blocks[0], dict) else {}
|
| 78 |
+
has_planned = "planned_min" in first
|
| 79 |
+
chatty = (
|
| 80 |
+
"intention" in payload
|
| 81 |
+
or "day_review" in payload
|
| 82 |
+
or "schema_version" in payload
|
| 83 |
+
or any(isinstance(b, dict) and "review" in b for b in blocks)
|
| 84 |
+
)
|
| 85 |
+
return has_planned and not chatty
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def unwrap_import_payload(body: dict[str, Any]) -> tuple[dict[str, Any], Literal["replace", "merge"]]:
|
| 89 |
+
mode_raw = str(body.get("mode") or "replace").lower()
|
| 90 |
+
mode: Literal["replace", "merge"] = "merge" if mode_raw == "merge" else "replace"
|
| 91 |
+
if isinstance(body.get("plan"), dict) and "blocks" in body["plan"]:
|
| 92 |
+
return body["plan"], mode
|
| 93 |
+
if "blocks" in body:
|
| 94 |
+
chat = {k: v for k, v in body.items() if k != "mode"}
|
| 95 |
+
return chat, mode
|
| 96 |
+
raise ChatPlanError(["Expected ChatPlan with blocks (or {plan, mode})"])
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def chat_plan_to_day_put(
|
| 100 |
+
chat: dict[str, Any],
|
| 101 |
+
day: str,
|
| 102 |
+
*,
|
| 103 |
+
max_blocks: int = 7,
|
| 104 |
+
previous_blocks: list[dict[str, Any]] | None = None,
|
| 105 |
+
mode: Literal["replace", "merge"] = "replace",
|
| 106 |
+
) -> tuple[DayPlanPut, list[BlockFeedback], list[str]]:
|
| 107 |
+
"""Map ChatPlan → DayPlanPut + optional feedback rows + warnings."""
|
| 108 |
+
|
| 109 |
+
errors: list[str] = []
|
| 110 |
+
warnings: list[str] = []
|
| 111 |
+
|
| 112 |
+
if chat.get("date") and str(chat["date"]) != day:
|
| 113 |
+
errors.append(f"date mismatch: JSON {chat['date']} vs URL {day}")
|
| 114 |
+
|
| 115 |
+
blocks_in = chat.get("blocks")
|
| 116 |
+
if not isinstance(blocks_in, list) or len(blocks_in) == 0:
|
| 117 |
+
errors.append("blocks must be a non-empty array")
|
| 118 |
+
raise ChatPlanError(errors)
|
| 119 |
+
|
| 120 |
+
if is_legacy_day_plan_put(chat):
|
| 121 |
+
put = DayPlanPut.model_validate({**chat, "source": chat.get("source") or "cursor"})
|
| 122 |
+
for block in put.blocks:
|
| 123 |
+
block.date = day
|
| 124 |
+
return put, [], warnings
|
| 125 |
+
|
| 126 |
+
mapped: list[ScheduledBlock] = []
|
| 127 |
+
feedbacks: list[BlockFeedback] = []
|
| 128 |
+
|
| 129 |
+
for raw in blocks_in:
|
| 130 |
+
if not isinstance(raw, dict):
|
| 131 |
+
errors.append("each block must be an object")
|
| 132 |
+
continue
|
| 133 |
+
start = str(raw.get("start") or "").strip()
|
| 134 |
+
end = str(raw.get("end") or "").strip()
|
| 135 |
+
title = str(raw.get("title") or "").strip() or "Untitled"
|
| 136 |
+
notes = str(raw.get("notes") or "")
|
| 137 |
+
kind, notes = _coerce_kind(raw.get("kind"), notes)
|
| 138 |
+
if str(raw.get("kind") or "") and str(raw.get("kind")) not in KIND_SET:
|
| 139 |
+
warnings.append(f"unknown kind coerced to other: {raw.get('kind')}")
|
| 140 |
+
intent = str(raw.get("intent") or "duty")
|
| 141 |
+
if intent not in INTENT_SET:
|
| 142 |
+
intent = "duty"
|
| 143 |
+
priority = str(raw.get("priority") or "P1")
|
| 144 |
+
if priority not in PRIORITY_SET:
|
| 145 |
+
priority = "P1"
|
| 146 |
+
review = raw.get("review") if isinstance(raw.get("review"), dict) else None
|
| 147 |
+
status = _status_from_review(str(raw.get("status") or "planned"), review)
|
| 148 |
+
try:
|
| 149 |
+
planned = int(raw.get("planned_min") or 0)
|
| 150 |
+
if planned <= 0:
|
| 151 |
+
planned = max(1, minutes_between(start, end))
|
| 152 |
+
except Exception: # noqa: BLE001
|
| 153 |
+
errors.append(f"invalid times for block {title}")
|
| 154 |
+
continue
|
| 155 |
+
block_id = str(raw.get("id") or "").strip() or str(uuid4())
|
| 156 |
+
locked = bool(raw.get("locked")) if raw.get("locked") is not None else priority == "P0"
|
| 157 |
+
block = ScheduledBlock(
|
| 158 |
+
id=block_id,
|
| 159 |
+
date=day,
|
| 160 |
+
start=start,
|
| 161 |
+
end=end,
|
| 162 |
+
title=title[:200],
|
| 163 |
+
kind=kind, # type: ignore[arg-type]
|
| 164 |
+
intent=intent, # type: ignore[arg-type]
|
| 165 |
+
priority=priority, # type: ignore[arg-type]
|
| 166 |
+
planned_min=planned,
|
| 167 |
+
status=status, # type: ignore[arg-type]
|
| 168 |
+
source="chat",
|
| 169 |
+
locked=locked,
|
| 170 |
+
notes=notes,
|
| 171 |
+
)
|
| 172 |
+
mapped.append(block)
|
| 173 |
+
if review and review.get("did") in DID_SET:
|
| 174 |
+
minutes = review.get("minutes")
|
| 175 |
+
intensity = review.get("intensity")
|
| 176 |
+
try:
|
| 177 |
+
intensity_i = int(intensity) if intensity is not None else None
|
| 178 |
+
except (TypeError, ValueError):
|
| 179 |
+
intensity_i = None
|
| 180 |
+
try:
|
| 181 |
+
actual = int(minutes) if minutes is not None else None
|
| 182 |
+
except (TypeError, ValueError):
|
| 183 |
+
actual = None
|
| 184 |
+
skip = review.get("skip_reason")
|
| 185 |
+
feedbacks.append(
|
| 186 |
+
BlockFeedback(
|
| 187 |
+
block_id=block_id,
|
| 188 |
+
date=day,
|
| 189 |
+
did=str(review["did"]), # type: ignore[arg-type]
|
| 190 |
+
actual_min=actual,
|
| 191 |
+
quality=review.get("quality"),
|
| 192 |
+
fun=review.get("fun"),
|
| 193 |
+
energy_after=review.get("energy_after"),
|
| 194 |
+
note=str(review.get("comment") or ""),
|
| 195 |
+
emotions=_as_list(review.get("emotions")),
|
| 196 |
+
fse_event=str(review.get("fse_event") or ""),
|
| 197 |
+
intensity=intensity_i,
|
| 198 |
+
skip_reason=skip if skip in {
|
| 199 |
+
"time", "fear", "fse", "locks", "boring", "urge", "other"
|
| 200 |
+
} else None,
|
| 201 |
+
)
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
if mode == "merge" and previous_blocks:
|
| 205 |
+
kept = list(previous_blocks)
|
| 206 |
+
for block in mapped:
|
| 207 |
+
conflict = False
|
| 208 |
+
for prev in kept:
|
| 209 |
+
if overlaps(
|
| 210 |
+
block.start,
|
| 211 |
+
block.end,
|
| 212 |
+
str(prev.get("start")),
|
| 213 |
+
str(prev.get("end")),
|
| 214 |
+
):
|
| 215 |
+
errors.append(
|
| 216 |
+
f"overlap merge conflict: {block.title} vs {prev.get('title')}"
|
| 217 |
+
)
|
| 218 |
+
conflict = True
|
| 219 |
+
break
|
| 220 |
+
if not conflict:
|
| 221 |
+
kept.append(block.model_dump())
|
| 222 |
+
# Rebuild mapped from kept
|
| 223 |
+
mapped = [ScheduledBlock.model_validate({**b, "date": day}) for b in kept]
|
| 224 |
+
|
| 225 |
+
val_errors, soft = validate_blocks(
|
| 226 |
+
[b.model_dump() for b in mapped],
|
| 227 |
+
max_blocks=max_blocks,
|
| 228 |
+
previous_p0=None,
|
| 229 |
+
allow_p0_move=True,
|
| 230 |
+
must_include_explore_or_restore=False,
|
| 231 |
+
capacity_hint=None,
|
| 232 |
+
)
|
| 233 |
+
errors.extend(val_errors)
|
| 234 |
+
warnings.extend(soft)
|
| 235 |
+
if errors:
|
| 236 |
+
raise ChatPlanError(errors)
|
| 237 |
+
|
| 238 |
+
review_raw = chat.get("day_review") if isinstance(chat.get("day_review"), dict) else {}
|
| 239 |
+
day_review = DayReview(
|
| 240 |
+
comment=str(review_raw.get("comment") or ""),
|
| 241 |
+
emotions=_as_list(review_raw.get("emotions")),
|
| 242 |
+
fse_events=str(review_raw.get("fse_events") or ""),
|
| 243 |
+
what_moved=str(review_raw.get("what_moved") or ""),
|
| 244 |
+
what_avoided=str(review_raw.get("what_avoided") or ""),
|
| 245 |
+
tomorrow_change=str(review_raw.get("tomorrow_change") or ""),
|
| 246 |
+
)
|
| 247 |
+
put = DayPlanPut(
|
| 248 |
+
blocks=mapped,
|
| 249 |
+
source="chat",
|
| 250 |
+
notes=str(chat.get("notes") or ""),
|
| 251 |
+
title=str(chat.get("title") or ""),
|
| 252 |
+
intention=str(chat.get("intention") or ""),
|
| 253 |
+
constraints=_as_list(chat.get("constraints")),
|
| 254 |
+
day_review=day_review,
|
| 255 |
+
force_p0_move=True,
|
| 256 |
+
)
|
| 257 |
+
return put, feedbacks, warnings
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def plan_and_feedback_to_chat_plan(
|
| 261 |
+
plan: DayPlan | dict[str, Any],
|
| 262 |
+
feedbacks_by_block_id: dict[str, dict[str, Any]],
|
| 263 |
+
) -> dict[str, Any]:
|
| 264 |
+
"""Build ChatPlan for export (includes summary)."""
|
| 265 |
+
|
| 266 |
+
if isinstance(plan, DayPlan):
|
| 267 |
+
pdata = plan.model_dump(mode="json")
|
| 268 |
+
else:
|
| 269 |
+
pdata = plan
|
| 270 |
+
|
| 271 |
+
blocks_out: list[dict[str, Any]] = []
|
| 272 |
+
blocks_done = 0
|
| 273 |
+
blocks_skipped = 0
|
| 274 |
+
p0_done = 0
|
| 275 |
+
p0_total = 0
|
| 276 |
+
planned_sum = 0
|
| 277 |
+
actual_sum = 0
|
| 278 |
+
|
| 279 |
+
for block in pdata.get("blocks") or []:
|
| 280 |
+
bid = str(block.get("id") or "")
|
| 281 |
+
fb = feedbacks_by_block_id.get(bid) or block.get("feedback") or {}
|
| 282 |
+
status = str(block.get("status") or "planned")
|
| 283 |
+
did = fb.get("did") if fb.get("did") in DID_SET else (
|
| 284 |
+
status if status in DID_SET else None
|
| 285 |
+
)
|
| 286 |
+
if status == "done" or did == "done":
|
| 287 |
+
blocks_done += 1
|
| 288 |
+
if status == "skipped" or did == "skipped":
|
| 289 |
+
blocks_skipped += 1
|
| 290 |
+
if block.get("priority") == "P0":
|
| 291 |
+
p0_total += 1
|
| 292 |
+
if status == "done" or did == "done":
|
| 293 |
+
p0_done += 1
|
| 294 |
+
planned_sum += int(block.get("planned_min") or 0)
|
| 295 |
+
if fb.get("actual_min") is not None:
|
| 296 |
+
actual_sum += int(fb.get("actual_min") or 0)
|
| 297 |
+
|
| 298 |
+
blocks_out.append(
|
| 299 |
+
{
|
| 300 |
+
"id": bid,
|
| 301 |
+
"start": block.get("start"),
|
| 302 |
+
"end": block.get("end"),
|
| 303 |
+
"title": block.get("title"),
|
| 304 |
+
"priority": block.get("priority"),
|
| 305 |
+
"kind": block.get("kind"),
|
| 306 |
+
"intent": block.get("intent"),
|
| 307 |
+
"notes": block.get("notes") or "",
|
| 308 |
+
"locked": bool(block.get("locked")),
|
| 309 |
+
"status": status if status in STATUS_SET else "planned",
|
| 310 |
+
"review": {
|
| 311 |
+
"did": did,
|
| 312 |
+
"minutes": fb.get("actual_min"),
|
| 313 |
+
"quality": fb.get("quality"),
|
| 314 |
+
"fun": fb.get("fun"),
|
| 315 |
+
"energy_after": fb.get("energy_after"),
|
| 316 |
+
"comment": fb.get("note") or "",
|
| 317 |
+
"emotions": list(fb.get("emotions") or []),
|
| 318 |
+
"fse_event": fb.get("fse_event") or "",
|
| 319 |
+
"intensity": fb.get("intensity"),
|
| 320 |
+
"skip_reason": fb.get("skip_reason"),
|
| 321 |
+
},
|
| 322 |
+
}
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
day_review = pdata.get("day_review") or {}
|
| 326 |
+
if hasattr(day_review, "model_dump"):
|
| 327 |
+
day_review = day_review.model_dump()
|
| 328 |
+
|
| 329 |
+
return {
|
| 330 |
+
"schema_version": 1,
|
| 331 |
+
"date": pdata.get("date"),
|
| 332 |
+
"title": pdata.get("title") or "",
|
| 333 |
+
"intention": pdata.get("intention") or "",
|
| 334 |
+
"constraints": list(pdata.get("constraints") or []),
|
| 335 |
+
"blocks": blocks_out,
|
| 336 |
+
"day_review": {
|
| 337 |
+
"comment": day_review.get("comment") or "",
|
| 338 |
+
"emotions": list(day_review.get("emotions") or []),
|
| 339 |
+
"fse_events": day_review.get("fse_events") or "",
|
| 340 |
+
"what_moved": day_review.get("what_moved") or "",
|
| 341 |
+
"what_avoided": day_review.get("what_avoided") or "",
|
| 342 |
+
"tomorrow_change": day_review.get("tomorrow_change") or "",
|
| 343 |
+
},
|
| 344 |
+
"summary": {
|
| 345 |
+
"blocks_total": len(blocks_out),
|
| 346 |
+
"blocks_done": blocks_done,
|
| 347 |
+
"blocks_skipped": blocks_skipped,
|
| 348 |
+
"p0_done": p0_done,
|
| 349 |
+
"p0_total": p0_total,
|
| 350 |
+
"planned_min_sum": planned_sum,
|
| 351 |
+
"actual_min_sum": actual_sum,
|
| 352 |
+
},
|
| 353 |
+
}
|
app/routers/plan.py
CHANGED
|
@@ -5,18 +5,33 @@ Session-authenticated; mirrors the app envelope pattern.
|
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
from datetime import date
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
from fastapi import APIRouter, Depends, Request, status
|
|
|
|
| 12 |
from pydantic import BaseModel
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from app.deps import require_login
|
| 15 |
from app.models import ApiEnvelope, err, ok
|
| 16 |
from app.schedule_math import capacity_hint, trigger_operators
|
| 17 |
from app.schedule_reschedule import seed_starter_blocks
|
| 18 |
from app.schedule_risk import risk_from_stores
|
| 19 |
-
from app.schedule_store import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
router = APIRouter(tags=["plan"], dependencies=[Depends(require_login)])
|
| 22 |
|
|
@@ -68,6 +83,15 @@ def _enrich_plan(request: Request, day: date) -> dict[str, Any]:
|
|
| 68 |
return data
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
@router.get("/api/plan/{day}", response_model=ApiEnvelope)
|
| 72 |
def get_plan(request: Request, day: date) -> dict[str, Any]:
|
| 73 |
return ok(_enrich_plan(request, day))
|
|
@@ -138,6 +162,105 @@ def post_feedback(
|
|
| 138 |
return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND)
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
@router.post("/api/plan/{day}/seed", response_model=ApiEnvelope)
|
| 142 |
def seed_plan(request: Request, day: date) -> object:
|
| 143 |
"""Fill an empty day from templates under live capacity (P0 locked)."""
|
|
|
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
+
import json
|
| 9 |
from datetime import date
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
from fastapi import APIRouter, Depends, Request, status
|
| 13 |
+
from fastapi.responses import Response
|
| 14 |
from pydantic import BaseModel
|
| 15 |
|
| 16 |
+
from app.chat_plan import (
|
| 17 |
+
ChatPlanError,
|
| 18 |
+
chat_plan_to_day_put,
|
| 19 |
+
plan_and_feedback_to_chat_plan,
|
| 20 |
+
unwrap_import_payload,
|
| 21 |
+
)
|
| 22 |
from app.deps import require_login
|
| 23 |
from app.models import ApiEnvelope, err, ok
|
| 24 |
from app.schedule_math import capacity_hint, trigger_operators
|
| 25 |
from app.schedule_reschedule import seed_starter_blocks
|
| 26 |
from app.schedule_risk import risk_from_stores
|
| 27 |
+
from app.schedule_store import (
|
| 28 |
+
BlockCheckBody,
|
| 29 |
+
BlockCreate,
|
| 30 |
+
BlockFeedback,
|
| 31 |
+
BlockPatch,
|
| 32 |
+
DayPlanPut,
|
| 33 |
+
PlanMetaPatch,
|
| 34 |
+
)
|
| 35 |
|
| 36 |
router = APIRouter(tags=["plan"], dependencies=[Depends(require_login)])
|
| 37 |
|
|
|
|
| 83 |
return data
|
| 84 |
|
| 85 |
|
| 86 |
+
def _feedback_map(store: Any, day: str) -> dict[str, dict[str, Any]]:
|
| 87 |
+
by_block: dict[str, dict[str, Any]] = {}
|
| 88 |
+
for row in store.feedback_for_plan(day):
|
| 89 |
+
bid = str(row.get("block_id") or "")
|
| 90 |
+
if bid:
|
| 91 |
+
by_block[bid] = row
|
| 92 |
+
return by_block
|
| 93 |
+
|
| 94 |
+
|
| 95 |
@router.get("/api/plan/{day}", response_model=ApiEnvelope)
|
| 96 |
def get_plan(request: Request, day: date) -> dict[str, Any]:
|
| 97 |
return ok(_enrich_plan(request, day))
|
|
|
|
| 162 |
return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND)
|
| 163 |
|
| 164 |
|
| 165 |
+
@router.post("/api/plan/{day}/blocks/{block_id}/check", response_model=ApiEnvelope)
|
| 166 |
+
def check_block(
|
| 167 |
+
request: Request,
|
| 168 |
+
day: date,
|
| 169 |
+
block_id: str,
|
| 170 |
+
body: BlockCheckBody,
|
| 171 |
+
) -> object:
|
| 172 |
+
"""Fast checkbox — status only, no full feedback required."""
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
_store(request).patch_block(
|
| 176 |
+
day.isoformat(),
|
| 177 |
+
block_id,
|
| 178 |
+
BlockPatch(status=body.status),
|
| 179 |
+
)
|
| 180 |
+
if body.status == "skipped" and body.skip_reason:
|
| 181 |
+
try:
|
| 182 |
+
_store(request).submit_feedback(
|
| 183 |
+
day.isoformat(),
|
| 184 |
+
BlockFeedback(
|
| 185 |
+
block_id=block_id,
|
| 186 |
+
date=day.isoformat(),
|
| 187 |
+
did="skipped",
|
| 188 |
+
skip_reason=body.skip_reason,
|
| 189 |
+
),
|
| 190 |
+
)
|
| 191 |
+
except KeyError:
|
| 192 |
+
pass
|
| 193 |
+
return ok(_enrich_plan(request, day))
|
| 194 |
+
except KeyError:
|
| 195 |
+
return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND)
|
| 196 |
+
except ValueError as exc:
|
| 197 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@router.patch("/api/plan/{day}/meta", response_model=ApiEnvelope)
|
| 201 |
+
def patch_plan_meta(request: Request, day: date, body: PlanMetaPatch) -> object:
|
| 202 |
+
try:
|
| 203 |
+
_store(request).patch_meta(day.isoformat(), body)
|
| 204 |
+
return ok(_enrich_plan(request, day))
|
| 205 |
+
except ValueError as exc:
|
| 206 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.post("/api/plan/{day}/import", response_model=ApiEnvelope)
|
| 210 |
+
def import_chat_plan(request: Request, day: date, body: dict[str, Any]) -> object:
|
| 211 |
+
store = _store(request)
|
| 212 |
+
day_s = day.isoformat()
|
| 213 |
+
try:
|
| 214 |
+
chat, mode = unwrap_import_payload(body)
|
| 215 |
+
current = store.get_plan(day_s)
|
| 216 |
+
put, feedbacks, warnings = chat_plan_to_day_put(
|
| 217 |
+
chat,
|
| 218 |
+
day_s,
|
| 219 |
+
max_blocks=request.app.state.settings.schedule_max_blocks,
|
| 220 |
+
previous_blocks=[b.model_dump() for b in current.blocks],
|
| 221 |
+
mode=mode,
|
| 222 |
+
)
|
| 223 |
+
store.save_plan(day_s, put, bump_version=True)
|
| 224 |
+
for fb in feedbacks:
|
| 225 |
+
try:
|
| 226 |
+
store.submit_feedback(day_s, fb)
|
| 227 |
+
except KeyError:
|
| 228 |
+
warnings.append(f"feedback skipped for missing block {fb.block_id}")
|
| 229 |
+
data = _enrich_plan(request, day)
|
| 230 |
+
data["import_warnings"] = warnings
|
| 231 |
+
data["import_mode"] = mode
|
| 232 |
+
return ok(data)
|
| 233 |
+
except ChatPlanError as exc:
|
| 234 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 235 |
+
except ValueError as exc:
|
| 236 |
+
return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@router.get("/api/plan/{day}/export", response_model=ApiEnvelope)
|
| 240 |
+
def export_chat_plan(request: Request, day: date) -> dict[str, Any]:
|
| 241 |
+
store = _store(request)
|
| 242 |
+
day_s = day.isoformat()
|
| 243 |
+
plan = store.get_plan(day_s)
|
| 244 |
+
chat = plan_and_feedback_to_chat_plan(plan, _feedback_map(store, day_s))
|
| 245 |
+
return ok(chat)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@router.get("/api/plan/{day}/export.txt")
|
| 249 |
+
def export_chat_plan_file(request: Request, day: date) -> Response:
|
| 250 |
+
store = _store(request)
|
| 251 |
+
day_s = day.isoformat()
|
| 252 |
+
plan = store.get_plan(day_s)
|
| 253 |
+
chat = plan_and_feedback_to_chat_plan(plan, _feedback_map(store, day_s))
|
| 254 |
+
body = json.dumps(chat, ensure_ascii=False, indent=2) + "\n"
|
| 255 |
+
return Response(
|
| 256 |
+
content=body,
|
| 257 |
+
media_type="application/json",
|
| 258 |
+
headers={
|
| 259 |
+
"Content-Disposition": f'attachment; filename="plan-{day_s}.json"'
|
| 260 |
+
},
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
@router.post("/api/plan/{day}/seed", response_model=ApiEnvelope)
|
| 265 |
def seed_plan(request: Request, day: date) -> object:
|
| 266 |
"""Fill an empty day from templates under live capacity (P0 locked)."""
|
app/schedule_store.py
CHANGED
|
@@ -44,13 +44,26 @@ BlockStatus = Literal["planned", "done", "partial", "skipped", "moved", "cancell
|
|
| 44 |
Did = Literal["done", "partial", "skipped"]
|
| 45 |
WouldRepeat = Literal["yes", "no", "maybe"]
|
| 46 |
SkipReason = Literal["time", "fear", "fse", "locks", "boring", "urge", "other"]
|
| 47 |
-
PlanSource = Literal["cursor", "openrouter", "user", "rules"]
|
| 48 |
|
| 49 |
|
| 50 |
def utc_now() -> datetime:
|
| 51 |
return datetime.now(timezone.utc)
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
class ScheduledBlock(BaseModel):
|
| 55 |
"""One timed block on a day plan."""
|
| 56 |
|
|
@@ -100,6 +113,9 @@ class BlockFeedback(BaseModel):
|
|
| 100 |
would_repeat: WouldRepeat | None = None
|
| 101 |
skip_reason: SkipReason | None = None
|
| 102 |
note: str = ""
|
|
|
|
|
|
|
|
|
|
| 103 |
strong: bool = False
|
| 104 |
ts: datetime = Field(default_factory=utc_now)
|
| 105 |
|
|
@@ -120,6 +136,10 @@ class DayPlan(BaseModel):
|
|
| 120 |
blocks: list[ScheduledBlock] = Field(default_factory=list)
|
| 121 |
capacity_hint: float = Field(default=1.0, ge=0.0, le=1.0)
|
| 122 |
notes: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
warnings: list[str] = Field(default_factory=list)
|
| 124 |
updated_at: datetime = Field(default_factory=utc_now)
|
| 125 |
parent_version: int | None = None
|
|
@@ -167,9 +187,33 @@ class DayPlanPut(BaseModel):
|
|
| 167 |
source: PlanSource | str = "user"
|
| 168 |
capacity_hint: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 169 |
notes: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
force_p0_move: bool = False
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
class ScheduleStore:
|
| 174 |
"""Read and mutate schedule plans and priors."""
|
| 175 |
|
|
@@ -203,7 +247,12 @@ class ScheduleStore:
|
|
| 203 |
data["date"] = day
|
| 204 |
if not data.get("planned_min"):
|
| 205 |
data["planned_min"] = max(1, minutes_between(data["start"], data["end"]))
|
| 206 |
-
if data.get("priority") == "P0" and put.source in (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
data["locked"] = True if data.get("locked") is None else data["locked"]
|
| 208 |
blocks.append(ScheduledBlock.model_validate(data))
|
| 209 |
|
|
@@ -224,13 +273,24 @@ class ScheduleStore:
|
|
| 224 |
version = current.version + 1 if bump_version and current.blocks else max(1, current.version)
|
| 225 |
if not current.blocks and not bump_version:
|
| 226 |
version = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
plan = DayPlan(
|
| 228 |
date=day,
|
| 229 |
version=version,
|
| 230 |
source=put.source,
|
| 231 |
blocks=blocks,
|
| 232 |
capacity_hint=put.capacity_hint if put.capacity_hint is not None else current.capacity_hint,
|
| 233 |
-
notes=put.notes,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
warnings=warnings,
|
| 235 |
updated_at=utc_now(),
|
| 236 |
parent_version=current.version if current.blocks else None,
|
|
@@ -238,6 +298,49 @@ class ScheduleStore:
|
|
| 238 |
self._write_plan(plan)
|
| 239 |
return plan
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
def _write_plan(self, plan: DayPlan) -> None:
|
| 242 |
path = self.paths.plan_path(plan.date)
|
| 243 |
payload = json.dumps(plan.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
|
@@ -263,17 +366,7 @@ class ScheduleStore:
|
|
| 263 |
version_added=plan.version,
|
| 264 |
)
|
| 265 |
blocks = list(plan.blocks) + [block]
|
| 266 |
-
return self.
|
| 267 |
-
day,
|
| 268 |
-
DayPlanPut(
|
| 269 |
-
blocks=blocks,
|
| 270 |
-
source="user",
|
| 271 |
-
capacity_hint=plan.capacity_hint,
|
| 272 |
-
notes=plan.notes,
|
| 273 |
-
force_p0_move=True,
|
| 274 |
-
),
|
| 275 |
-
bump_version=False,
|
| 276 |
-
)
|
| 277 |
|
| 278 |
def patch_block(self, day: str, block_id: str, patch: BlockPatch) -> DayPlan:
|
| 279 |
plan = self.get_plan(day)
|
|
@@ -293,34 +386,14 @@ class ScheduleStore:
|
|
| 293 |
blocks.append(ScheduledBlock.model_validate(data))
|
| 294 |
if not found:
|
| 295 |
raise KeyError(block_id)
|
| 296 |
-
return self.
|
| 297 |
-
day,
|
| 298 |
-
DayPlanPut(
|
| 299 |
-
blocks=blocks,
|
| 300 |
-
source="user",
|
| 301 |
-
capacity_hint=plan.capacity_hint,
|
| 302 |
-
notes=plan.notes,
|
| 303 |
-
force_p0_move=True,
|
| 304 |
-
),
|
| 305 |
-
bump_version=False,
|
| 306 |
-
)
|
| 307 |
|
| 308 |
def delete_block(self, day: str, block_id: str) -> DayPlan:
|
| 309 |
plan = self.get_plan(day)
|
| 310 |
blocks = [b for b in plan.blocks if b.id != block_id]
|
| 311 |
if len(blocks) == len(plan.blocks):
|
| 312 |
raise KeyError(block_id)
|
| 313 |
-
return self.
|
| 314 |
-
day,
|
| 315 |
-
DayPlanPut(
|
| 316 |
-
blocks=blocks,
|
| 317 |
-
source="user",
|
| 318 |
-
capacity_hint=plan.capacity_hint,
|
| 319 |
-
notes=plan.notes,
|
| 320 |
-
force_p0_move=True,
|
| 321 |
-
),
|
| 322 |
-
bump_version=False,
|
| 323 |
-
)
|
| 324 |
|
| 325 |
def list_feedback(self, day: str | None = None) -> list[dict[str, Any]]:
|
| 326 |
rows = read_jsonl(self.paths.schedule_feedback)
|
|
|
|
| 44 |
Did = Literal["done", "partial", "skipped"]
|
| 45 |
WouldRepeat = Literal["yes", "no", "maybe"]
|
| 46 |
SkipReason = Literal["time", "fear", "fse", "locks", "boring", "urge", "other"]
|
| 47 |
+
PlanSource = Literal["cursor", "openrouter", "user", "rules", "chat"]
|
| 48 |
|
| 49 |
|
| 50 |
def utc_now() -> datetime:
|
| 51 |
return datetime.now(timezone.utc)
|
| 52 |
|
| 53 |
|
| 54 |
+
class DayReview(BaseModel):
|
| 55 |
+
"""End-of-day review for chat export / import."""
|
| 56 |
+
|
| 57 |
+
model_config = ConfigDict(extra="ignore")
|
| 58 |
+
|
| 59 |
+
comment: str = ""
|
| 60 |
+
emotions: list[str] = Field(default_factory=list)
|
| 61 |
+
fse_events: str = ""
|
| 62 |
+
what_moved: str = ""
|
| 63 |
+
what_avoided: str = ""
|
| 64 |
+
tomorrow_change: str = ""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
class ScheduledBlock(BaseModel):
|
| 68 |
"""One timed block on a day plan."""
|
| 69 |
|
|
|
|
| 113 |
would_repeat: WouldRepeat | None = None
|
| 114 |
skip_reason: SkipReason | None = None
|
| 115 |
note: str = ""
|
| 116 |
+
emotions: list[str] = Field(default_factory=list)
|
| 117 |
+
fse_event: str = ""
|
| 118 |
+
intensity: int | None = Field(default=None, ge=1, le=10)
|
| 119 |
strong: bool = False
|
| 120 |
ts: datetime = Field(default_factory=utc_now)
|
| 121 |
|
|
|
|
| 136 |
blocks: list[ScheduledBlock] = Field(default_factory=list)
|
| 137 |
capacity_hint: float = Field(default=1.0, ge=0.0, le=1.0)
|
| 138 |
notes: str = ""
|
| 139 |
+
title: str = ""
|
| 140 |
+
intention: str = ""
|
| 141 |
+
constraints: list[str] = Field(default_factory=list)
|
| 142 |
+
day_review: DayReview = Field(default_factory=DayReview)
|
| 143 |
warnings: list[str] = Field(default_factory=list)
|
| 144 |
updated_at: datetime = Field(default_factory=utc_now)
|
| 145 |
parent_version: int | None = None
|
|
|
|
| 187 |
source: PlanSource | str = "user"
|
| 188 |
capacity_hint: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 189 |
notes: str = ""
|
| 190 |
+
title: str | None = None
|
| 191 |
+
intention: str | None = None
|
| 192 |
+
constraints: list[str] | None = None
|
| 193 |
+
day_review: DayReview | None = None
|
| 194 |
force_p0_move: bool = False
|
| 195 |
|
| 196 |
|
| 197 |
+
class PlanMetaPatch(BaseModel):
|
| 198 |
+
"""Day-level chat meta without touching blocks."""
|
| 199 |
+
|
| 200 |
+
model_config = ConfigDict(extra="forbid")
|
| 201 |
+
|
| 202 |
+
title: str | None = None
|
| 203 |
+
intention: str | None = None
|
| 204 |
+
constraints: list[str] | None = None
|
| 205 |
+
day_review: DayReview | None = None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class BlockCheckBody(BaseModel):
|
| 209 |
+
"""Fast checkbox status without full feedback."""
|
| 210 |
+
|
| 211 |
+
model_config = ConfigDict(extra="forbid")
|
| 212 |
+
|
| 213 |
+
status: Literal["done", "partial", "skipped", "planned"]
|
| 214 |
+
skip_reason: SkipReason | None = None
|
| 215 |
+
|
| 216 |
+
|
| 217 |
class ScheduleStore:
|
| 218 |
"""Read and mutate schedule plans and priors."""
|
| 219 |
|
|
|
|
| 247 |
data["date"] = day
|
| 248 |
if not data.get("planned_min"):
|
| 249 |
data["planned_min"] = max(1, minutes_between(data["start"], data["end"]))
|
| 250 |
+
if data.get("priority") == "P0" and put.source in (
|
| 251 |
+
"openrouter",
|
| 252 |
+
"rules",
|
| 253 |
+
"cursor",
|
| 254 |
+
"chat",
|
| 255 |
+
):
|
| 256 |
data["locked"] = True if data.get("locked") is None else data["locked"]
|
| 257 |
blocks.append(ScheduledBlock.model_validate(data))
|
| 258 |
|
|
|
|
| 273 |
version = current.version + 1 if bump_version and current.blocks else max(1, current.version)
|
| 274 |
if not current.blocks and not bump_version:
|
| 275 |
version = 1
|
| 276 |
+
day_review = (
|
| 277 |
+
put.day_review
|
| 278 |
+
if put.day_review is not None
|
| 279 |
+
else current.day_review
|
| 280 |
+
)
|
| 281 |
plan = DayPlan(
|
| 282 |
date=day,
|
| 283 |
version=version,
|
| 284 |
source=put.source,
|
| 285 |
blocks=blocks,
|
| 286 |
capacity_hint=put.capacity_hint if put.capacity_hint is not None else current.capacity_hint,
|
| 287 |
+
notes=put.notes if put.notes is not None else current.notes,
|
| 288 |
+
title=put.title if put.title is not None else current.title,
|
| 289 |
+
intention=put.intention if put.intention is not None else current.intention,
|
| 290 |
+
constraints=(
|
| 291 |
+
put.constraints if put.constraints is not None else current.constraints
|
| 292 |
+
),
|
| 293 |
+
day_review=day_review,
|
| 294 |
warnings=warnings,
|
| 295 |
updated_at=utc_now(),
|
| 296 |
parent_version=current.version if current.blocks else None,
|
|
|
|
| 298 |
self._write_plan(plan)
|
| 299 |
return plan
|
| 300 |
|
| 301 |
+
def patch_meta(self, day: str, patch: PlanMetaPatch) -> DayPlan:
|
| 302 |
+
plan = self.get_plan(day)
|
| 303 |
+
data = plan.model_dump()
|
| 304 |
+
if patch.title is not None:
|
| 305 |
+
data["title"] = patch.title
|
| 306 |
+
if patch.intention is not None:
|
| 307 |
+
data["intention"] = patch.intention
|
| 308 |
+
if patch.constraints is not None:
|
| 309 |
+
data["constraints"] = patch.constraints
|
| 310 |
+
if patch.day_review is not None:
|
| 311 |
+
data["day_review"] = patch.day_review.model_dump()
|
| 312 |
+
data["updated_at"] = utc_now().isoformat()
|
| 313 |
+
updated = DayPlan.model_validate(data)
|
| 314 |
+
self._write_plan(updated)
|
| 315 |
+
return updated
|
| 316 |
+
|
| 317 |
+
def put_preserving_meta(
|
| 318 |
+
self,
|
| 319 |
+
day: str,
|
| 320 |
+
blocks: list[ScheduledBlock],
|
| 321 |
+
*,
|
| 322 |
+
source: str = "user",
|
| 323 |
+
force_p0_move: bool = True,
|
| 324 |
+
bump_version: bool = False,
|
| 325 |
+
notes: str | None = None,
|
| 326 |
+
) -> DayPlan:
|
| 327 |
+
plan = self.get_plan(day)
|
| 328 |
+
return self.save_plan(
|
| 329 |
+
day,
|
| 330 |
+
DayPlanPut(
|
| 331 |
+
blocks=blocks,
|
| 332 |
+
source=source,
|
| 333 |
+
capacity_hint=plan.capacity_hint,
|
| 334 |
+
notes=notes if notes is not None else plan.notes,
|
| 335 |
+
title=plan.title,
|
| 336 |
+
intention=plan.intention,
|
| 337 |
+
constraints=plan.constraints,
|
| 338 |
+
day_review=plan.day_review,
|
| 339 |
+
force_p0_move=force_p0_move,
|
| 340 |
+
),
|
| 341 |
+
bump_version=bump_version,
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
def _write_plan(self, plan: DayPlan) -> None:
|
| 345 |
path = self.paths.plan_path(plan.date)
|
| 346 |
payload = json.dumps(plan.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
|
|
|
| 366 |
version_added=plan.version,
|
| 367 |
)
|
| 368 |
blocks = list(plan.blocks) + [block]
|
| 369 |
+
return self.put_preserving_meta(day, blocks, bump_version=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
def patch_block(self, day: str, block_id: str, patch: BlockPatch) -> DayPlan:
|
| 372 |
plan = self.get_plan(day)
|
|
|
|
| 386 |
blocks.append(ScheduledBlock.model_validate(data))
|
| 387 |
if not found:
|
| 388 |
raise KeyError(block_id)
|
| 389 |
+
return self.put_preserving_meta(day, blocks, bump_version=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
def delete_block(self, day: str, block_id: str) -> DayPlan:
|
| 392 |
plan = self.get_plan(day)
|
| 393 |
blocks = [b for b in plan.blocks if b.id != block_id]
|
| 394 |
if len(blocks) == len(plan.blocks):
|
| 395 |
raise KeyError(block_id)
|
| 396 |
+
return self.put_preserving_meta(day, blocks, bump_version=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
|
| 398 |
def list_feedback(self, day: str | None = None) -> list[dict[str, Any]]:
|
| 399 |
rows = read_jsonl(self.paths.schedule_feedback)
|
docs/SCHEDULE.md
CHANGED
|
@@ -132,7 +132,18 @@ TZ / SCHEDULE_TIMEZONE=Africa/Dar_es_Salaam
|
|
| 132 |
|-------|------|
|
| 133 |
| `#/plan` | Day timeline 06:00–23:00, now line, colored blocks |
|
| 134 |
| Block sheet | Complete with did / minutes / quality / fun (no PERMA Qs) |
|
| 135 |
-
| Home | Next 1–2 blocks + overdue feedback banner |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
## Reschedule
|
| 138 |
|
|
|
|
| 132 |
|-------|------|
|
| 133 |
| `#/plan` | Day timeline 06:00–23:00, now line, colored blocks |
|
| 134 |
| Block sheet | Complete with did / minutes / quality / fun (no PERMA Qs) |
|
| 135 |
+
| Home | Next 1–2 blocks + overdue feedback banner; empty → Paste plan CTA |
|
| 136 |
+
| Plan | Paste ChatPlan / Export for chat / checklist check-off / day review |
|
| 137 |
+
|
| 138 |
+
### ChatPlan loop (external AI)
|
| 139 |
+
|
| 140 |
+
- `POST /api/plan/{day}/import` — ChatPlan JSON (or `{plan, mode}`) → timetable
|
| 141 |
+
- `GET /api/plan/{day}/export` — filled ChatPlan in envelope
|
| 142 |
+
- `GET /api/plan/{day}/export.txt` — downloadable JSON attachment
|
| 143 |
+
- `POST /api/plan/{day}/blocks/{id}/check` — fast status without thick feedback
|
| 144 |
+
- `PATCH /api/plan/{day}/meta` — title / intention / constraints / day_review
|
| 145 |
+
- Feedback additive: `emotions[]`, `fse_event`, `intensity`
|
| 146 |
+
|
| 147 |
|
| 148 |
## Reschedule
|
| 149 |
|
frontend/src/api.ts
CHANGED
|
@@ -408,10 +408,22 @@ export type BlockFeedback = {
|
|
| 408 |
would_repeat: "yes" | "no" | "maybe" | null;
|
| 409 |
skip_reason: string | null;
|
| 410 |
note: string;
|
|
|
|
|
|
|
|
|
|
| 411 |
strong: boolean;
|
| 412 |
ts: string;
|
| 413 |
};
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
export type DayPlanView = {
|
| 416 |
date: string;
|
| 417 |
version: number;
|
|
@@ -419,6 +431,11 @@ export type DayPlanView = {
|
|
| 419 |
blocks: ScheduledBlock[];
|
| 420 |
capacity_hint: number;
|
| 421 |
notes: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
updated_at: string;
|
| 423 |
parent_version: number | null;
|
| 424 |
health?: {
|
|
@@ -428,6 +445,26 @@ export type DayPlanView = {
|
|
| 428 |
p0_done_rate: number;
|
| 429 |
explore_or_restore_done: boolean;
|
| 430 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
};
|
| 432 |
|
| 433 |
export type ScheduleTemplate = {
|
|
@@ -530,6 +567,47 @@ export function seedPlan(day: string): Promise<DayPlanView> {
|
|
| 530 |
return api(`/api/plan/${day}/seed`, { method: "POST", body: "{}" });
|
| 531 |
}
|
| 532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
export type LoopSummary = {
|
| 534 |
days: number;
|
| 535 |
as_of: string;
|
|
|
|
| 408 |
would_repeat: "yes" | "no" | "maybe" | null;
|
| 409 |
skip_reason: string | null;
|
| 410 |
note: string;
|
| 411 |
+
emotions?: string[];
|
| 412 |
+
fse_event?: string;
|
| 413 |
+
intensity?: number | null;
|
| 414 |
strong: boolean;
|
| 415 |
ts: string;
|
| 416 |
};
|
| 417 |
|
| 418 |
+
export type DayReview = {
|
| 419 |
+
comment: string;
|
| 420 |
+
emotions: string[];
|
| 421 |
+
fse_events: string;
|
| 422 |
+
what_moved: string;
|
| 423 |
+
what_avoided: string;
|
| 424 |
+
tomorrow_change: string;
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
export type DayPlanView = {
|
| 428 |
date: string;
|
| 429 |
version: number;
|
|
|
|
| 431 |
blocks: ScheduledBlock[];
|
| 432 |
capacity_hint: number;
|
| 433 |
notes: string;
|
| 434 |
+
title?: string;
|
| 435 |
+
intention?: string;
|
| 436 |
+
constraints?: string[];
|
| 437 |
+
day_review?: DayReview;
|
| 438 |
+
warnings?: string[];
|
| 439 |
updated_at: string;
|
| 440 |
parent_version: number | null;
|
| 441 |
health?: {
|
|
|
|
| 445 |
p0_done_rate: number;
|
| 446 |
explore_or_restore_done: boolean;
|
| 447 |
};
|
| 448 |
+
import_warnings?: string[];
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
+
export type ChatPlan = {
|
| 452 |
+
schema_version: number;
|
| 453 |
+
date: string;
|
| 454 |
+
title: string;
|
| 455 |
+
intention: string;
|
| 456 |
+
constraints: string[];
|
| 457 |
+
blocks: Array<Record<string, unknown>>;
|
| 458 |
+
day_review: DayReview;
|
| 459 |
+
summary?: {
|
| 460 |
+
blocks_total: number;
|
| 461 |
+
blocks_done: number;
|
| 462 |
+
blocks_skipped: number;
|
| 463 |
+
p0_done: number;
|
| 464 |
+
p0_total: number;
|
| 465 |
+
planned_min_sum: number;
|
| 466 |
+
actual_min_sum: number;
|
| 467 |
+
};
|
| 468 |
};
|
| 469 |
|
| 470 |
export type ScheduleTemplate = {
|
|
|
|
| 567 |
return api(`/api/plan/${day}/seed`, { method: "POST", body: "{}" });
|
| 568 |
}
|
| 569 |
|
| 570 |
+
export function importChatPlan(
|
| 571 |
+
day: string,
|
| 572 |
+
plan: unknown,
|
| 573 |
+
mode: "replace" | "merge" = "replace",
|
| 574 |
+
): Promise<DayPlanView> {
|
| 575 |
+
return api(`/api/plan/${day}/import`, {
|
| 576 |
+
method: "POST",
|
| 577 |
+
body: JSON.stringify({ plan, mode }),
|
| 578 |
+
});
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
export function exportChatPlan(day: string): Promise<ChatPlan> {
|
| 582 |
+
return api(`/api/plan/${day}/export`);
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
export function checkPlanBlock(
|
| 586 |
+
day: string,
|
| 587 |
+
id: string,
|
| 588 |
+
payload: { status: "done" | "partial" | "skipped" | "planned"; skip_reason?: string },
|
| 589 |
+
): Promise<DayPlanView> {
|
| 590 |
+
return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}/check`, {
|
| 591 |
+
method: "POST",
|
| 592 |
+
body: JSON.stringify(payload),
|
| 593 |
+
});
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
export function patchPlanMeta(
|
| 597 |
+
day: string,
|
| 598 |
+
payload: {
|
| 599 |
+
title?: string;
|
| 600 |
+
intention?: string;
|
| 601 |
+
constraints?: string[];
|
| 602 |
+
day_review?: Partial<DayReview>;
|
| 603 |
+
},
|
| 604 |
+
): Promise<DayPlanView> {
|
| 605 |
+
return api(`/api/plan/${day}/meta`, {
|
| 606 |
+
method: "PATCH",
|
| 607 |
+
body: JSON.stringify(payload),
|
| 608 |
+
});
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
export type LoopSummary = {
|
| 612 |
days: number;
|
| 613 |
as_of: string;
|
frontend/src/components/BlockSheet.tsx
CHANGED
|
@@ -57,6 +57,10 @@ export function BlockSheet({ open, day, block, mode, onClose, onChanged }: Props
|
|
| 57 |
const [skipReason, setSkipReason] = useState("");
|
| 58 |
const [note, setNote] = useState("");
|
| 59 |
const [money, setMoney] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
const [busy, setBusy] = useState(false);
|
| 61 |
const [templates, setTemplates] = useState<ScheduleTemplate[]>([]);
|
| 62 |
|
|
@@ -75,15 +79,19 @@ export function BlockSheet({ open, day, block, mode, onClose, onChanged }: Props
|
|
| 75 |
.catch(() => setTemplates([]));
|
| 76 |
}
|
| 77 |
if (mode === "view" && block) {
|
| 78 |
-
setActualMin(block.planned_min
|
| 79 |
-
setDid("done");
|
| 80 |
-
setQuality(3);
|
| 81 |
-
setFun(3);
|
| 82 |
-
setEnergy(0);
|
| 83 |
-
setWouldRepeat("");
|
| 84 |
-
setSkipReason("");
|
| 85 |
-
setNote("");
|
| 86 |
-
setMoney("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
if (mode === "create") {
|
| 89 |
setTitle("New block");
|
|
@@ -117,13 +125,16 @@ export function BlockSheet({ open, day, block, mode, onClose, onChanged }: Props
|
|
| 117 |
await postBlockFeedback(day, block.id, {
|
| 118 |
did,
|
| 119 |
actual_min: did === "skipped" ? null : actualMin,
|
| 120 |
-
quality: did === "skipped" ? null : quality,
|
| 121 |
-
fun: did === "skipped" ? null : fun,
|
| 122 |
-
energy_after: energy,
|
| 123 |
money_amount: money ? Number(money) : null,
|
| 124 |
would_repeat: wouldRepeat || null,
|
| 125 |
skip_reason: did === "skipped" ? skipReason || "other" : null,
|
| 126 |
note,
|
|
|
|
|
|
|
|
|
|
| 127 |
});
|
| 128 |
toast.show("Saved");
|
| 129 |
onChanged();
|
|
@@ -270,18 +281,87 @@ export function BlockSheet({ open, day, block, mode, onClose, onChanged }: Props
|
|
| 270 |
))}
|
| 271 |
</div>
|
| 272 |
{did !== "skipped" && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
<>
|
| 274 |
-
<label class="field-label">
|
| 275 |
-
Actual minutes
|
| 276 |
-
<input
|
| 277 |
-
class="field-input"
|
| 278 |
-
type="number"
|
| 279 |
-
min={0}
|
| 280 |
-
max={24 * 60}
|
| 281 |
-
value={actualMin}
|
| 282 |
-
onInput={(e) => setActualMin(Number((e.target as HTMLInputElement).value))}
|
| 283 |
-
/>
|
| 284 |
-
</label>
|
| 285 |
<span class="field-label">Quality</span>
|
| 286 |
<div class="segment-row">
|
| 287 |
{[1, 2, 3, 4, 5].map((n) => (
|
|
@@ -298,75 +378,46 @@ export function BlockSheet({ open, day, block, mode, onClose, onChanged }: Props
|
|
| 298 |
</Pressable>
|
| 299 |
))}
|
| 300 |
</div>
|
| 301 |
-
|
| 302 |
-
)}
|
| 303 |
-
<span class="field-label">Energy after</span>
|
| 304 |
-
<div class="segment-row">
|
| 305 |
-
{([-2, -1, 0, 1, 2] as const).map((v) => (
|
| 306 |
-
<Pressable
|
| 307 |
-
key={String(v)}
|
| 308 |
-
className="segment"
|
| 309 |
-
ariaPressed={energy === v}
|
| 310 |
-
onClick={() => setEnergy(v)}
|
| 311 |
-
>
|
| 312 |
-
{v > 0 ? `+${v}` : String(v)}
|
| 313 |
-
</Pressable>
|
| 314 |
-
))}
|
| 315 |
-
</div>
|
| 316 |
-
{did !== "skipped" && (
|
| 317 |
-
<>
|
| 318 |
-
<span class="field-label">Would repeat?</span>
|
| 319 |
<div class="segment-row">
|
| 320 |
-
{([
|
| 321 |
<Pressable
|
| 322 |
-
key={v}
|
| 323 |
className="segment"
|
| 324 |
-
ariaPressed={
|
| 325 |
-
onClick={() =>
|
| 326 |
>
|
| 327 |
-
{v}
|
| 328 |
</Pressable>
|
| 329 |
))}
|
| 330 |
</div>
|
| 331 |
-
|
| 332 |
-
)}
|
| 333 |
-
{did === "skipped" && (
|
| 334 |
-
<>
|
| 335 |
-
<span class="field-label">Why skipped?</span>
|
| 336 |
<div class="segment-row">
|
| 337 |
-
{["
|
| 338 |
<Pressable
|
| 339 |
key={v}
|
| 340 |
className="segment"
|
| 341 |
-
ariaPressed={
|
| 342 |
-
onClick={() =>
|
| 343 |
>
|
| 344 |
{v}
|
| 345 |
</Pressable>
|
| 346 |
))}
|
| 347 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
</>
|
| 349 |
)}
|
| 350 |
-
<
|
| 351 |
-
Money (optional, TZS)
|
| 352 |
-
<input
|
| 353 |
-
class="field-input"
|
| 354 |
-
inputMode="decimal"
|
| 355 |
-
value={money}
|
| 356 |
-
onInput={(e) => setMoney((e.target as HTMLInputElement).value)}
|
| 357 |
-
/>
|
| 358 |
-
</label>
|
| 359 |
-
<label class="field-label">
|
| 360 |
-
Note
|
| 361 |
-
<input
|
| 362 |
-
class="field-input"
|
| 363 |
-
value={note}
|
| 364 |
-
onInput={(e) => setNote((e.target as HTMLInputElement).value)}
|
| 365 |
-
/>
|
| 366 |
-
</label>
|
| 367 |
-
<div class="resolve-sticky stack">
|
| 368 |
<Button className="btn-large" disabled={busy} onClick={saveFeedback}>
|
| 369 |
-
{busy ? "Saving…" : "Save
|
| 370 |
</Button>
|
| 371 |
<Button className="btn-secondary" disabled={busy} onClick={removeBlock}>
|
| 372 |
Remove block
|
|
|
|
| 57 |
const [skipReason, setSkipReason] = useState("");
|
| 58 |
const [note, setNote] = useState("");
|
| 59 |
const [money, setMoney] = useState("");
|
| 60 |
+
const [emotions, setEmotions] = useState<string[]>([]);
|
| 61 |
+
const [fseEvent, setFseEvent] = useState("");
|
| 62 |
+
const [intensity, setIntensity] = useState(5);
|
| 63 |
+
const [showThick, setShowThick] = useState(false);
|
| 64 |
const [busy, setBusy] = useState(false);
|
| 65 |
const [templates, setTemplates] = useState<ScheduleTemplate[]>([]);
|
| 66 |
|
|
|
|
| 79 |
.catch(() => setTemplates([]));
|
| 80 |
}
|
| 81 |
if (mode === "view" && block) {
|
| 82 |
+
setActualMin(block.feedback?.actual_min ?? block.planned_min ?? 30);
|
| 83 |
+
setDid((block.feedback?.did as typeof did) || (block.status === "skipped" ? "skipped" : "done"));
|
| 84 |
+
setQuality(block.feedback?.quality ?? 3);
|
| 85 |
+
setFun(block.feedback?.fun ?? 3);
|
| 86 |
+
setEnergy(block.feedback?.energy_after ?? 0);
|
| 87 |
+
setWouldRepeat((block.feedback?.would_repeat as typeof wouldRepeat) || "");
|
| 88 |
+
setSkipReason(block.feedback?.skip_reason || "");
|
| 89 |
+
setNote(block.feedback?.note || "");
|
| 90 |
+
setMoney(block.feedback?.money_amount != null ? String(block.feedback.money_amount) : "");
|
| 91 |
+
setEmotions(block.feedback?.emotions || []);
|
| 92 |
+
setFseEvent(block.feedback?.fse_event || "");
|
| 93 |
+
setIntensity(block.feedback?.intensity ?? 5);
|
| 94 |
+
setShowThick(false);
|
| 95 |
}
|
| 96 |
if (mode === "create") {
|
| 97 |
setTitle("New block");
|
|
|
|
| 125 |
await postBlockFeedback(day, block.id, {
|
| 126 |
did,
|
| 127 |
actual_min: did === "skipped" ? null : actualMin,
|
| 128 |
+
quality: did === "skipped" || !showThick ? null : quality,
|
| 129 |
+
fun: did === "skipped" || !showThick ? null : fun,
|
| 130 |
+
energy_after: showThick ? energy : null,
|
| 131 |
money_amount: money ? Number(money) : null,
|
| 132 |
would_repeat: wouldRepeat || null,
|
| 133 |
skip_reason: did === "skipped" ? skipReason || "other" : null,
|
| 134 |
note,
|
| 135 |
+
emotions,
|
| 136 |
+
fse_event: fseEvent,
|
| 137 |
+
intensity: did === "skipped" ? null : intensity,
|
| 138 |
});
|
| 139 |
toast.show("Saved");
|
| 140 |
onChanged();
|
|
|
|
| 281 |
))}
|
| 282 |
</div>
|
| 283 |
{did !== "skipped" && (
|
| 284 |
+
<label class="field-label">
|
| 285 |
+
Actual minutes
|
| 286 |
+
<input
|
| 287 |
+
class="field-input"
|
| 288 |
+
type="number"
|
| 289 |
+
min={0}
|
| 290 |
+
max={24 * 60}
|
| 291 |
+
value={actualMin}
|
| 292 |
+
onInput={(e) => setActualMin(Number((e.target as HTMLInputElement).value))}
|
| 293 |
+
/>
|
| 294 |
+
</label>
|
| 295 |
+
)}
|
| 296 |
+
<label class="field-label">
|
| 297 |
+
Comment
|
| 298 |
+
<textarea
|
| 299 |
+
class="field-textarea"
|
| 300 |
+
value={note}
|
| 301 |
+
onInput={(e) => setNote((e.target as HTMLTextAreaElement).value)}
|
| 302 |
+
placeholder="What happened?"
|
| 303 |
+
/>
|
| 304 |
+
</label>
|
| 305 |
+
<span class="field-label">Emotions</span>
|
| 306 |
+
<div class="chip-row" role="group">
|
| 307 |
+
{["calm", "shame", "urge", "anxiety", "anger", "lonely", "tired", "hope"].map((em) => (
|
| 308 |
+
<Pressable
|
| 309 |
+
key={em}
|
| 310 |
+
className="chip"
|
| 311 |
+
ariaPressed={emotions.includes(em)}
|
| 312 |
+
onClick={() =>
|
| 313 |
+
setEmotions((cur) =>
|
| 314 |
+
cur.includes(em) ? cur.filter((x) => x !== em) : [...cur, em],
|
| 315 |
+
)
|
| 316 |
+
}
|
| 317 |
+
>
|
| 318 |
+
{em}
|
| 319 |
+
</Pressable>
|
| 320 |
+
))}
|
| 321 |
+
</div>
|
| 322 |
+
<label class="field-label">
|
| 323 |
+
FSE event (short phrase)
|
| 324 |
+
<input
|
| 325 |
+
class="field-input"
|
| 326 |
+
value={fseEvent}
|
| 327 |
+
onInput={(e) => setFseEvent((e.target as HTMLInputElement).value)}
|
| 328 |
+
placeholder="What spiked?"
|
| 329 |
+
/>
|
| 330 |
+
</label>
|
| 331 |
+
{did !== "skipped" && (
|
| 332 |
+
<label class="field-label">
|
| 333 |
+
Intensity · {intensity}
|
| 334 |
+
<input
|
| 335 |
+
type="range"
|
| 336 |
+
min={1}
|
| 337 |
+
max={10}
|
| 338 |
+
value={intensity}
|
| 339 |
+
onInput={(e) => setIntensity(Number((e.target as HTMLInputElement).value))}
|
| 340 |
+
/>
|
| 341 |
+
</label>
|
| 342 |
+
)}
|
| 343 |
+
{did === "skipped" && (
|
| 344 |
+
<>
|
| 345 |
+
<span class="field-label">Why skipped?</span>
|
| 346 |
+
<div class="segment-row">
|
| 347 |
+
{["time", "fear", "urge", "boring", "fse", "other"].map((v) => (
|
| 348 |
+
<Pressable
|
| 349 |
+
key={v}
|
| 350 |
+
className="segment"
|
| 351 |
+
ariaPressed={skipReason === v}
|
| 352 |
+
onClick={() => setSkipReason(v)}
|
| 353 |
+
>
|
| 354 |
+
{v}
|
| 355 |
+
</Pressable>
|
| 356 |
+
))}
|
| 357 |
+
</div>
|
| 358 |
+
</>
|
| 359 |
+
)}
|
| 360 |
+
<Pressable className="btn btn-plain" onClick={() => setShowThick((v) => !v)}>
|
| 361 |
+
{showThick ? "Hide quality / fun / energy" : "More: quality, fun, energy, money"}
|
| 362 |
+
</Pressable>
|
| 363 |
+
{showThick && did !== "skipped" && (
|
| 364 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
<span class="field-label">Quality</span>
|
| 366 |
<div class="segment-row">
|
| 367 |
{[1, 2, 3, 4, 5].map((n) => (
|
|
|
|
| 378 |
</Pressable>
|
| 379 |
))}
|
| 380 |
</div>
|
| 381 |
+
<span class="field-label">Energy after</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
<div class="segment-row">
|
| 383 |
+
{([-2, -1, 0, 1, 2] as const).map((v) => (
|
| 384 |
<Pressable
|
| 385 |
+
key={String(v)}
|
| 386 |
className="segment"
|
| 387 |
+
ariaPressed={energy === v}
|
| 388 |
+
onClick={() => setEnergy(v)}
|
| 389 |
>
|
| 390 |
+
{v > 0 ? `+${v}` : String(v)}
|
| 391 |
</Pressable>
|
| 392 |
))}
|
| 393 |
</div>
|
| 394 |
+
<span class="field-label">Would repeat?</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
<div class="segment-row">
|
| 396 |
+
{(["yes", "maybe", "no"] as const).map((v) => (
|
| 397 |
<Pressable
|
| 398 |
key={v}
|
| 399 |
className="segment"
|
| 400 |
+
ariaPressed={wouldRepeat === v}
|
| 401 |
+
onClick={() => setWouldRepeat(v)}
|
| 402 |
>
|
| 403 |
{v}
|
| 404 |
</Pressable>
|
| 405 |
))}
|
| 406 |
</div>
|
| 407 |
+
<label class="field-label">
|
| 408 |
+
Money (optional)
|
| 409 |
+
<input
|
| 410 |
+
class="field-input"
|
| 411 |
+
type="number"
|
| 412 |
+
value={money}
|
| 413 |
+
onInput={(e) => setMoney((e.target as HTMLInputElement).value)}
|
| 414 |
+
/>
|
| 415 |
+
</label>
|
| 416 |
</>
|
| 417 |
)}
|
| 418 |
+
<div class="resolve-sticky">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
<Button className="btn-large" disabled={busy} onClick={saveFeedback}>
|
| 420 |
+
{busy ? "Saving…" : "Save how it went"}
|
| 421 |
</Button>
|
| 422 |
<Button className="btn-secondary" disabled={busy} onClick={removeBlock}>
|
| 423 |
Remove block
|
frontend/src/pages/Home.tsx
CHANGED
|
@@ -262,7 +262,12 @@ export function Home() {
|
|
| 262 |
</Pressable>
|
| 263 |
</div>
|
| 264 |
{!nextBlocks.length ? (
|
| 265 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
) : (
|
| 267 |
<div class="stack">
|
| 268 |
{nextBlocks.map((b) => (
|
|
@@ -272,7 +277,11 @@ export function Home() {
|
|
| 272 |
onClick={() => navigate("/plan")}
|
| 273 |
>
|
| 274 |
<span class="entry-row-main">
|
| 275 |
-
<span class="entry-preview">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
<time>
|
| 277 |
{b.start}–{b.end}
|
| 278 |
</time>
|
|
|
|
| 262 |
</Pressable>
|
| 263 |
</div>
|
| 264 |
{!nextBlocks.length ? (
|
| 265 |
+
<div class="stack">
|
| 266 |
+
<p class="muted">No upcoming blocks.</p>
|
| 267 |
+
<Pressable className="chip-btn" onClick={() => navigate("/plan")}>
|
| 268 |
+
Paste plan from chat
|
| 269 |
+
</Pressable>
|
| 270 |
+
</div>
|
| 271 |
) : (
|
| 272 |
<div class="stack">
|
| 273 |
{nextBlocks.map((b) => (
|
|
|
|
| 277 |
onClick={() => navigate("/plan")}
|
| 278 |
>
|
| 279 |
<span class="entry-row-main">
|
| 280 |
+
<span class="entry-preview">
|
| 281 |
+
{b.status === "planned" ? "○ " : "✓ "}
|
| 282 |
+
{b.priority === "P0" ? "P0 · " : ""}
|
| 283 |
+
{b.title}
|
| 284 |
+
</span>
|
| 285 |
<time>
|
| 286 |
{b.start}–{b.end}
|
| 287 |
</time>
|
frontend/src/pages/Plan.tsx
CHANGED
|
@@ -1,20 +1,24 @@
|
|
| 1 |
-
/** Plan day
|
| 2 |
-
|
| 3 |
-
Nav IA: Home | Plan | (+) Log | Daily | Settings (History via Home).
|
| 4 |
-
*/
|
| 5 |
|
| 6 |
import { ChevronLeft, ChevronRight } from "lucide-preact";
|
| 7 |
-
import { useEffect, useState } from "preact/hooks";
|
| 8 |
import {
|
|
|
|
|
|
|
|
|
|
| 9 |
getPlan,
|
|
|
|
|
|
|
| 10 |
reschedulePlan,
|
| 11 |
seedPlan,
|
| 12 |
type DayPlanView,
|
|
|
|
| 13 |
type ScheduledBlock,
|
| 14 |
} from "../api";
|
| 15 |
import { BlockSheet } from "../components/BlockSheet";
|
| 16 |
import { Button } from "../components/Button";
|
| 17 |
import { Pressable } from "../components/Pressable";
|
|
|
|
| 18 |
import { Timeline } from "../components/Timeline";
|
| 19 |
import { useToast } from "../components/Toast";
|
| 20 |
import { isoDate } from "../dates";
|
|
@@ -34,6 +38,20 @@ function friendly(day: string): string {
|
|
| 34 |
});
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
export function Plan({ initialDate }: { initialDate?: string }) {
|
| 38 |
const toast = useToast();
|
| 39 |
const [day, setDay] = useState(initialDate || isoDate(new Date()));
|
|
@@ -42,12 +60,20 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 42 |
const [selected, setSelected] = useState<ScheduledBlock | null>(null);
|
| 43 |
const [sheetMode, setSheetMode] = useState<"view" | "create">("view");
|
| 44 |
const [sheetOpen, setSheetOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
const load = async (date: string) => {
|
| 47 |
try {
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
} catch {
|
| 50 |
setPlan(null);
|
|
|
|
| 51 |
}
|
| 52 |
};
|
| 53 |
|
|
@@ -60,6 +86,15 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 60 |
navigate(`/plan/${day}`);
|
| 61 |
}, [day]);
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
const openBlock = (block: ScheduledBlock) => {
|
| 64 |
setSelected(block);
|
| 65 |
setSheetMode("view");
|
|
@@ -98,9 +133,95 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 98 |
}
|
| 99 |
};
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
const capacity = plan?.capacity_hint ?? 1;
|
| 102 |
const health = plan?.health;
|
| 103 |
const empty = !plan?.blocks.length;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
return (
|
| 106 |
<div class="app-shell">
|
|
@@ -130,7 +251,21 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 130 |
Health {Math.round(health.score * 100)}% · v{plan?.version ?? 1}
|
| 131 |
</span>
|
| 132 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
{capacity < 0.55 && (
|
| 135 |
<div class="tip-card" role="status">
|
| 136 |
<p>High load risk — lighter plan recommended.</p>
|
|
@@ -138,6 +273,12 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 138 |
)}
|
| 139 |
|
| 140 |
<div class="plan-actions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
<Button className="btn-secondary" disabled={busy} onClick={openCreate}>
|
| 142 |
+ Block
|
| 143 |
</Button>
|
|
@@ -146,10 +287,18 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 146 |
</Button>
|
| 147 |
</div>
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
{empty ? (
|
| 150 |
<section class="surface-card empty-card stack">
|
| 151 |
-
<p>No blocks yet.
|
| 152 |
-
<Button disabled={busy} onClick={
|
|
|
|
|
|
|
|
|
|
| 153 |
Seed starter day
|
| 154 |
</Button>
|
| 155 |
<Button className="btn-secondary" disabled={busy} onClick={openCreate}>
|
|
@@ -157,10 +306,119 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 157 |
</Button>
|
| 158 |
</section>
|
| 159 |
) : (
|
| 160 |
-
<
|
| 161 |
-
<
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</main>
|
| 165 |
|
| 166 |
<BlockSheet
|
|
@@ -171,6 +429,40 @@ export function Plan({ initialDate }: { initialDate?: string }) {
|
|
| 171 |
onClose={() => setSheetOpen(false)}
|
| 172 |
onChanged={() => void load(day)}
|
| 173 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
</div>
|
| 175 |
);
|
| 176 |
}
|
|
|
|
| 1 |
+
/** Plan day: timeline, paste ChatPlan, check-off, annotate, day review, export. */
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import { ChevronLeft, ChevronRight } from "lucide-preact";
|
| 4 |
+
import { useEffect, useMemo, useState } from "preact/hooks";
|
| 5 |
import {
|
| 6 |
+
ApiError,
|
| 7 |
+
checkPlanBlock,
|
| 8 |
+
exportChatPlan,
|
| 9 |
getPlan,
|
| 10 |
+
importChatPlan,
|
| 11 |
+
patchPlanMeta,
|
| 12 |
reschedulePlan,
|
| 13 |
seedPlan,
|
| 14 |
type DayPlanView,
|
| 15 |
+
type DayReview,
|
| 16 |
type ScheduledBlock,
|
| 17 |
} from "../api";
|
| 18 |
import { BlockSheet } from "../components/BlockSheet";
|
| 19 |
import { Button } from "../components/Button";
|
| 20 |
import { Pressable } from "../components/Pressable";
|
| 21 |
+
import { Sheet } from "../components/Sheet";
|
| 22 |
import { Timeline } from "../components/Timeline";
|
| 23 |
import { useToast } from "../components/Toast";
|
| 24 |
import { isoDate } from "../dates";
|
|
|
|
| 38 |
});
|
| 39 |
}
|
| 40 |
|
| 41 |
+
function parseMin(hhmm: string): number {
|
| 42 |
+
const [h, m] = hhmm.split(":").map(Number);
|
| 43 |
+
return h * 60 + m;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const EMPTY_REVIEW: DayReview = {
|
| 47 |
+
comment: "",
|
| 48 |
+
emotions: [],
|
| 49 |
+
fse_events: "",
|
| 50 |
+
what_moved: "",
|
| 51 |
+
what_avoided: "",
|
| 52 |
+
tomorrow_change: "",
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
export function Plan({ initialDate }: { initialDate?: string }) {
|
| 56 |
const toast = useToast();
|
| 57 |
const [day, setDay] = useState(initialDate || isoDate(new Date()));
|
|
|
|
| 60 |
const [selected, setSelected] = useState<ScheduledBlock | null>(null);
|
| 61 |
const [sheetMode, setSheetMode] = useState<"view" | "create">("view");
|
| 62 |
const [sheetOpen, setSheetOpen] = useState(false);
|
| 63 |
+
const [pasteOpen, setPasteOpen] = useState(false);
|
| 64 |
+
const [pasteText, setPasteText] = useState("");
|
| 65 |
+
const [pasteMode, setPasteMode] = useState<"replace" | "merge">("replace");
|
| 66 |
+
const [pasteError, setPasteError] = useState("");
|
| 67 |
+
const [dayReview, setDayReview] = useState<DayReview>(EMPTY_REVIEW);
|
| 68 |
|
| 69 |
const load = async (date: string) => {
|
| 70 |
try {
|
| 71 |
+
const next = await getPlan(date);
|
| 72 |
+
setPlan(next);
|
| 73 |
+
setDayReview({ ...EMPTY_REVIEW, ...(next.day_review || {}) });
|
| 74 |
} catch {
|
| 75 |
setPlan(null);
|
| 76 |
+
setDayReview(EMPTY_REVIEW);
|
| 77 |
}
|
| 78 |
};
|
| 79 |
|
|
|
|
| 86 |
navigate(`/plan/${day}`);
|
| 87 |
}, [day]);
|
| 88 |
|
| 89 |
+
const progress = useMemo(() => {
|
| 90 |
+
const blocks = plan?.blocks ?? [];
|
| 91 |
+
const total = blocks.length;
|
| 92 |
+
const done = blocks.filter((b) => b.status === "done" || b.status === "partial").length;
|
| 93 |
+
const p0 = blocks.filter((b) => b.priority === "P0");
|
| 94 |
+
const p0Left = p0.filter((b) => b.status === "planned").length;
|
| 95 |
+
return { total, done, p0Left, p0Total: p0.length };
|
| 96 |
+
}, [plan]);
|
| 97 |
+
|
| 98 |
const openBlock = (block: ScheduledBlock) => {
|
| 99 |
setSelected(block);
|
| 100 |
setSheetMode("view");
|
|
|
|
| 133 |
}
|
| 134 |
};
|
| 135 |
|
| 136 |
+
const onExport = async () => {
|
| 137 |
+
setBusy(true);
|
| 138 |
+
try {
|
| 139 |
+
const chat = await exportChatPlan(day);
|
| 140 |
+
await navigator.clipboard.writeText(JSON.stringify(chat, null, 2));
|
| 141 |
+
toast.show("Copied — paste to your chat");
|
| 142 |
+
} catch (e) {
|
| 143 |
+
toast.show(e instanceof Error ? e.message : "Export failed", "error");
|
| 144 |
+
} finally {
|
| 145 |
+
setBusy(false);
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const onPasteSubmit = async () => {
|
| 150 |
+
setPasteError("");
|
| 151 |
+
let parsed: unknown;
|
| 152 |
+
try {
|
| 153 |
+
parsed = JSON.parse(pasteText);
|
| 154 |
+
} catch {
|
| 155 |
+
setPasteError("Not valid JSON");
|
| 156 |
+
return;
|
| 157 |
+
}
|
| 158 |
+
setBusy(true);
|
| 159 |
+
try {
|
| 160 |
+
const next = await importChatPlan(day, parsed, pasteMode);
|
| 161 |
+
setPlan(next);
|
| 162 |
+
setDayReview({ ...EMPTY_REVIEW, ...(next.day_review || {}) });
|
| 163 |
+
setPasteOpen(false);
|
| 164 |
+
setPasteText("");
|
| 165 |
+
toast.show("Plan pasted");
|
| 166 |
+
if (next.import_warnings?.length) {
|
| 167 |
+
toast.show(next.import_warnings[0], "error");
|
| 168 |
+
}
|
| 169 |
+
} catch (e) {
|
| 170 |
+
const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Import failed";
|
| 171 |
+
setPasteError(msg);
|
| 172 |
+
} finally {
|
| 173 |
+
setBusy(false);
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
const toggleCheck = async (block: ScheduledBlock) => {
|
| 178 |
+
const nextStatus = block.status === "done" || block.status === "partial" ? "planned" : "done";
|
| 179 |
+
setBusy(true);
|
| 180 |
+
try {
|
| 181 |
+
setPlan(await checkPlanBlock(day, block.id, { status: nextStatus }));
|
| 182 |
+
} catch (e) {
|
| 183 |
+
toast.show(e instanceof Error ? e.message : "Check failed", "error");
|
| 184 |
+
} finally {
|
| 185 |
+
setBusy(false);
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const skipBlock = async (block: ScheduledBlock) => {
|
| 190 |
+
setBusy(true);
|
| 191 |
+
try {
|
| 192 |
+
setPlan(
|
| 193 |
+
await checkPlanBlock(day, block.id, {
|
| 194 |
+
status: "skipped",
|
| 195 |
+
skip_reason: "other",
|
| 196 |
+
}),
|
| 197 |
+
);
|
| 198 |
+
} catch (e) {
|
| 199 |
+
toast.show(e instanceof Error ? e.message : "Skip failed", "error");
|
| 200 |
+
} finally {
|
| 201 |
+
setBusy(false);
|
| 202 |
+
}
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const saveDayReview = async () => {
|
| 206 |
+
setBusy(true);
|
| 207 |
+
try {
|
| 208 |
+
const next = await patchPlanMeta(day, { day_review: dayReview });
|
| 209 |
+
setPlan(next);
|
| 210 |
+
toast.show("Day review saved");
|
| 211 |
+
} catch (e) {
|
| 212 |
+
toast.show(e instanceof Error ? e.message : "Save failed", "error");
|
| 213 |
+
} finally {
|
| 214 |
+
setBusy(false);
|
| 215 |
+
}
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
const capacity = plan?.capacity_hint ?? 1;
|
| 219 |
const health = plan?.health;
|
| 220 |
const empty = !plan?.blocks.length;
|
| 221 |
+
const nowMin = new Date().getHours() * 60 + new Date().getMinutes();
|
| 222 |
+
const sorted = [...(plan?.blocks ?? [])].sort(
|
| 223 |
+
(a, b) => parseMin(a.start) - parseMin(b.start),
|
| 224 |
+
);
|
| 225 |
|
| 226 |
return (
|
| 227 |
<div class="app-shell">
|
|
|
|
| 251 |
Health {Math.round(health.score * 100)}% · v{plan?.version ?? 1}
|
| 252 |
</span>
|
| 253 |
)}
|
| 254 |
+
{!empty && (
|
| 255 |
+
<span class="caption">
|
| 256 |
+
{progress.done}/{progress.total}
|
| 257 |
+
{progress.p0Total ? ` · P0 left ${progress.p0Left}` : ""}
|
| 258 |
+
</span>
|
| 259 |
+
)}
|
| 260 |
</div>
|
| 261 |
+
|
| 262 |
+
{(plan?.title || plan?.intention) && (
|
| 263 |
+
<section class="tip-card" role="status">
|
| 264 |
+
{plan?.title ? <strong>{plan.title}</strong> : null}
|
| 265 |
+
{plan?.intention ? <p class="muted">{plan.intention}</p> : null}
|
| 266 |
+
</section>
|
| 267 |
+
)}
|
| 268 |
+
|
| 269 |
{capacity < 0.55 && (
|
| 270 |
<div class="tip-card" role="status">
|
| 271 |
<p>High load risk — lighter plan recommended.</p>
|
|
|
|
| 273 |
)}
|
| 274 |
|
| 275 |
<div class="plan-actions">
|
| 276 |
+
<Button className="btn-secondary" disabled={busy} onClick={() => setPasteOpen(true)}>
|
| 277 |
+
Paste plan
|
| 278 |
+
</Button>
|
| 279 |
+
<Button className="btn-secondary" disabled={busy || empty} onClick={onExport}>
|
| 280 |
+
Export / Copy
|
| 281 |
+
</Button>
|
| 282 |
<Button className="btn-secondary" disabled={busy} onClick={openCreate}>
|
| 283 |
+ Block
|
| 284 |
</Button>
|
|
|
|
| 287 |
</Button>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
+
<p class="muted">
|
| 291 |
+
Paste the JSON your chat wrote. Check off when done in the real world. Export and paste
|
| 292 |
+
back for review.
|
| 293 |
+
</p>
|
| 294 |
+
|
| 295 |
{empty ? (
|
| 296 |
<section class="surface-card empty-card stack">
|
| 297 |
+
<p>No blocks yet. Paste a plan from chat, seed a starter day, or add one block.</p>
|
| 298 |
+
<Button disabled={busy} onClick={() => setPasteOpen(true)}>
|
| 299 |
+
Paste plan from chat
|
| 300 |
+
</Button>
|
| 301 |
+
<Button className="btn-secondary" disabled={busy} onClick={onSeed}>
|
| 302 |
Seed starter day
|
| 303 |
</Button>
|
| 304 |
<Button className="btn-secondary" disabled={busy} onClick={openCreate}>
|
|
|
|
| 306 |
</Button>
|
| 307 |
</section>
|
| 308 |
) : (
|
| 309 |
+
<>
|
| 310 |
+
<section class="surface-card stack" aria-label="Checklist">
|
| 311 |
+
<h2 class="section-title">Checklist</h2>
|
| 312 |
+
{sorted.map((block) => {
|
| 313 |
+
const overdue =
|
| 314 |
+
day === isoDate(new Date()) &&
|
| 315 |
+
block.status === "planned" &&
|
| 316 |
+
parseMin(block.end) < nowMin;
|
| 317 |
+
const checked = block.status === "done" || block.status === "partial";
|
| 318 |
+
return (
|
| 319 |
+
<div
|
| 320 |
+
key={block.id}
|
| 321 |
+
class={`entry-row ${overdue ? "indoors-nudge" : ""}`.trim()}
|
| 322 |
+
>
|
| 323 |
+
<Pressable
|
| 324 |
+
className="chip"
|
| 325 |
+
ariaPressed={checked}
|
| 326 |
+
ariaLabel={checked ? "Mark planned" : "Mark done"}
|
| 327 |
+
onClick={() => void toggleCheck(block)}
|
| 328 |
+
>
|
| 329 |
+
{checked ? "✓" : "○"}
|
| 330 |
+
</Pressable>
|
| 331 |
+
<Pressable className="entry-row-main" onClick={() => openBlock(block)}>
|
| 332 |
+
<span class="entry-preview">
|
| 333 |
+
{block.priority === "P0" ? "P0 · " : ""}
|
| 334 |
+
{block.title}
|
| 335 |
+
</span>
|
| 336 |
+
<time>
|
| 337 |
+
{block.start}–{block.end}
|
| 338 |
+
{overdue ? " · overdue" : ""}
|
| 339 |
+
</time>
|
| 340 |
+
</Pressable>
|
| 341 |
+
{block.status === "planned" && (
|
| 342 |
+
<Pressable className="chip-btn" onClick={() => void skipBlock(block)}>
|
| 343 |
+
Skip
|
| 344 |
+
</Pressable>
|
| 345 |
+
)}
|
| 346 |
+
</div>
|
| 347 |
+
);
|
| 348 |
+
})}
|
| 349 |
+
</section>
|
| 350 |
+
|
| 351 |
+
<section class="surface-card timeline-card">
|
| 352 |
+
<Timeline blocks={plan!.blocks} onSelect={openBlock} />
|
| 353 |
+
</section>
|
| 354 |
+
</>
|
| 355 |
)}
|
| 356 |
+
|
| 357 |
+
<section class="surface-card form-card stack" aria-label="Day review">
|
| 358 |
+
<h2 class="section-title">Day review</h2>
|
| 359 |
+
<p class="muted">One line is enough. FSE = fear / shame / embarrassment spike — short phrase.</p>
|
| 360 |
+
<label class="field-label" for="dr-comment">
|
| 361 |
+
Comment
|
| 362 |
+
</label>
|
| 363 |
+
<textarea
|
| 364 |
+
id="dr-comment"
|
| 365 |
+
class="field-textarea"
|
| 366 |
+
value={dayReview.comment}
|
| 367 |
+
onInput={(e) =>
|
| 368 |
+
setDayReview({ ...dayReview, comment: (e.target as HTMLTextAreaElement).value })
|
| 369 |
+
}
|
| 370 |
+
/>
|
| 371 |
+
<label class="field-label" for="dr-fse">
|
| 372 |
+
FSE events
|
| 373 |
+
</label>
|
| 374 |
+
<input
|
| 375 |
+
id="dr-fse"
|
| 376 |
+
class="field-input"
|
| 377 |
+
value={dayReview.fse_events}
|
| 378 |
+
onInput={(e) =>
|
| 379 |
+
setDayReview({ ...dayReview, fse_events: (e.target as HTMLInputElement).value })
|
| 380 |
+
}
|
| 381 |
+
/>
|
| 382 |
+
<label class="field-label" for="dr-moved">
|
| 383 |
+
What moved
|
| 384 |
+
</label>
|
| 385 |
+
<input
|
| 386 |
+
id="dr-moved"
|
| 387 |
+
class="field-input"
|
| 388 |
+
value={dayReview.what_moved}
|
| 389 |
+
onInput={(e) =>
|
| 390 |
+
setDayReview({ ...dayReview, what_moved: (e.target as HTMLInputElement).value })
|
| 391 |
+
}
|
| 392 |
+
/>
|
| 393 |
+
<label class="field-label" for="dr-avoided">
|
| 394 |
+
What avoided
|
| 395 |
+
</label>
|
| 396 |
+
<input
|
| 397 |
+
id="dr-avoided"
|
| 398 |
+
class="field-input"
|
| 399 |
+
value={dayReview.what_avoided}
|
| 400 |
+
onInput={(e) =>
|
| 401 |
+
setDayReview({ ...dayReview, what_avoided: (e.target as HTMLInputElement).value })
|
| 402 |
+
}
|
| 403 |
+
/>
|
| 404 |
+
<label class="field-label" for="dr-tomorrow">
|
| 405 |
+
Tomorrow change
|
| 406 |
+
</label>
|
| 407 |
+
<input
|
| 408 |
+
id="dr-tomorrow"
|
| 409 |
+
class="field-input"
|
| 410 |
+
value={dayReview.tomorrow_change}
|
| 411 |
+
onInput={(e) =>
|
| 412 |
+
setDayReview({
|
| 413 |
+
...dayReview,
|
| 414 |
+
tomorrow_change: (e.target as HTMLInputElement).value,
|
| 415 |
+
})
|
| 416 |
+
}
|
| 417 |
+
/>
|
| 418 |
+
<Button disabled={busy} onClick={() => void saveDayReview()}>
|
| 419 |
+
Save day review
|
| 420 |
+
</Button>
|
| 421 |
+
</section>
|
| 422 |
</main>
|
| 423 |
|
| 424 |
<BlockSheet
|
|
|
|
| 429 |
onClose={() => setSheetOpen(false)}
|
| 430 |
onChanged={() => void load(day)}
|
| 431 |
/>
|
| 432 |
+
|
| 433 |
+
<Sheet open={pasteOpen} title="Paste plan" onClose={() => setPasteOpen(false)} tall>
|
| 434 |
+
<div class="stack">
|
| 435 |
+
<p class="muted">Paste the JSON your chat wrote.</p>
|
| 436 |
+
<textarea
|
| 437 |
+
class="field-textarea"
|
| 438 |
+
rows={14}
|
| 439 |
+
value={pasteText}
|
| 440 |
+
onInput={(e) => setPasteText((e.target as HTMLTextAreaElement).value)}
|
| 441 |
+
placeholder='{ "schema_version": 1, "blocks": [ ... ] }'
|
| 442 |
+
/>
|
| 443 |
+
<span class="field-label">Mode</span>
|
| 444 |
+
<div class="chip-row" role="group">
|
| 445 |
+
<Pressable
|
| 446 |
+
className="chip"
|
| 447 |
+
ariaPressed={pasteMode === "replace"}
|
| 448 |
+
onClick={() => setPasteMode("replace")}
|
| 449 |
+
>
|
| 450 |
+
Replace
|
| 451 |
+
</Pressable>
|
| 452 |
+
<Pressable
|
| 453 |
+
className="chip"
|
| 454 |
+
ariaPressed={pasteMode === "merge"}
|
| 455 |
+
onClick={() => setPasteMode("merge")}
|
| 456 |
+
>
|
| 457 |
+
Merge
|
| 458 |
+
</Pressable>
|
| 459 |
+
</div>
|
| 460 |
+
{pasteError ? <p class="muted" role="alert">{pasteError}</p> : null}
|
| 461 |
+
<Button disabled={busy || !pasteText.trim()} onClick={() => void onPasteSubmit()}>
|
| 462 |
+
Import plan
|
| 463 |
+
</Button>
|
| 464 |
+
</div>
|
| 465 |
+
</Sheet>
|
| 466 |
</div>
|
| 467 |
);
|
| 468 |
}
|
static/assets/index-DYDM6L-p.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/assets/index-DYDM6L-p.js.map
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/assets/index-kJxQGPW0.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/assets/index-kJxQGPW0.js.map
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/index.html
CHANGED
|
@@ -20,7 +20,7 @@
|
|
| 20 |
<link rel="manifest" href="/manifest.webmanifest" />
|
| 21 |
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
| 22 |
<title>Habit Journal</title>
|
| 23 |
-
<script type="module" crossorigin src="/assets/index-
|
| 24 |
<link rel="stylesheet" crossorigin href="/assets/index-CK7MU-TQ.css">
|
| 25 |
</head>
|
| 26 |
<body>
|
|
|
|
| 20 |
<link rel="manifest" href="/manifest.webmanifest" />
|
| 21 |
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
| 22 |
<title>Habit Journal</title>
|
| 23 |
+
<script type="module" crossorigin src="/assets/index-DYDM6L-p.js"></script>
|
| 24 |
<link rel="stylesheet" crossorigin href="/assets/index-CK7MU-TQ.css">
|
| 25 |
</head>
|
| 26 |
<body>
|
static/sw.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
const CACHE = "habit-journal-v3";
|
| 3 |
-
const SHELL = ["/","/index.html","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/illustrations/hero-home.svg","/illustrations/pending.svg","/illustrations/scores.svg","/illustrations/coach.svg","/illustrations/empty.svg","/assets/index-CK7MU-TQ.css","/assets/index-
|
| 4 |
self.addEventListener("install", event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())));
|
| 5 |
self.addEventListener("activate", event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
| 6 |
self.addEventListener("fetch", event => {
|
|
|
|
| 1 |
|
| 2 |
const CACHE = "habit-journal-v3";
|
| 3 |
+
const SHELL = ["/","/index.html","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/illustrations/hero-home.svg","/illustrations/pending.svg","/illustrations/scores.svg","/illustrations/coach.svg","/illustrations/empty.svg","/assets/index-CK7MU-TQ.css","/assets/index-DYDM6L-p.js","/assets/index-DYDM6L-p.js.map"];
|
| 4 |
self.addEventListener("install", event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())));
|
| 5 |
self.addEventListener("activate", event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
| 6 |
self.addEventListener("fetch", event => {
|
tests/test_chat_plan.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChatPlan mapper and import/export round-trip tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import date
|
| 6 |
+
|
| 7 |
+
from app.chat_plan import (
|
| 8 |
+
ChatPlanError,
|
| 9 |
+
chat_plan_to_day_put,
|
| 10 |
+
plan_and_feedback_to_chat_plan,
|
| 11 |
+
)
|
| 12 |
+
from app.schedule_store import DayPlan, DayReview, ScheduledBlock
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
SAMPLE = {
|
| 16 |
+
"schema_version": 1,
|
| 17 |
+
"date": "2026-07-24",
|
| 18 |
+
"title": "Spain pack day",
|
| 19 |
+
"intention": "AXA + stay proof + hold PDF. No embassy. No flight pay.",
|
| 20 |
+
"constraints": ["wallet home on walk", "Weeknd only after proof"],
|
| 21 |
+
"blocks": [
|
| 22 |
+
{
|
| 23 |
+
"start": "08:00",
|
| 24 |
+
"end": "08:25",
|
| 25 |
+
"title": "Interrupt walk (no headphones)",
|
| 26 |
+
"priority": "P0",
|
| 27 |
+
"kind": "body_care",
|
| 28 |
+
"intent": "measure",
|
| 29 |
+
"notes": "Eyes outside.",
|
| 30 |
+
"status": "planned",
|
| 31 |
+
"review": {
|
| 32 |
+
"did": None,
|
| 33 |
+
"minutes": None,
|
| 34 |
+
"comment": "",
|
| 35 |
+
"emotions": [],
|
| 36 |
+
"fse_event": "",
|
| 37 |
+
"intensity": None,
|
| 38 |
+
},
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"start": "09:00",
|
| 42 |
+
"end": "10:30",
|
| 43 |
+
"title": "Fix AXA residence Tanzania",
|
| 44 |
+
"priority": "P0",
|
| 45 |
+
"kind": "admin_spain",
|
| 46 |
+
"intent": "duty",
|
| 47 |
+
"notes": "26 Aug–5 Sep 2026; PDF",
|
| 48 |
+
"locked": True,
|
| 49 |
+
"status": "planned",
|
| 50 |
+
"review": {
|
| 51 |
+
"did": None,
|
| 52 |
+
"minutes": None,
|
| 53 |
+
"comment": "",
|
| 54 |
+
"emotions": [],
|
| 55 |
+
"fse_event": "",
|
| 56 |
+
"intensity": None,
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
+
],
|
| 60 |
+
"day_review": {
|
| 61 |
+
"comment": "",
|
| 62 |
+
"emotions": [],
|
| 63 |
+
"fse_events": "",
|
| 64 |
+
"what_moved": "",
|
| 65 |
+
"what_avoided": "",
|
| 66 |
+
"tomorrow_change": "",
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_chat_to_put_basic() -> None:
|
| 72 |
+
put, fbs, warnings = chat_plan_to_day_put(SAMPLE, "2026-07-24")
|
| 73 |
+
assert put.title == "Spain pack day"
|
| 74 |
+
assert put.intention.startswith("AXA")
|
| 75 |
+
assert put.constraints == ["wallet home on walk", "Weeknd only after proof"]
|
| 76 |
+
assert len(put.blocks) == 2
|
| 77 |
+
assert put.blocks[0].planned_min == 25
|
| 78 |
+
assert put.blocks[1].kind == "admin_spain"
|
| 79 |
+
assert put.source == "chat"
|
| 80 |
+
assert fbs == []
|
| 81 |
+
assert isinstance(warnings, list)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_date_mismatch() -> None:
|
| 85 |
+
try:
|
| 86 |
+
chat_plan_to_day_put(SAMPLE, "2026-07-25")
|
| 87 |
+
assert False, "expected error"
|
| 88 |
+
except ChatPlanError as exc:
|
| 89 |
+
assert any("date mismatch" in e for e in exc.errors)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_empty_blocks() -> None:
|
| 93 |
+
try:
|
| 94 |
+
chat_plan_to_day_put({"blocks": []}, "2026-07-24")
|
| 95 |
+
assert False
|
| 96 |
+
except ChatPlanError:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_unknown_kind_coerced() -> None:
|
| 101 |
+
chat = {
|
| 102 |
+
"blocks": [
|
| 103 |
+
{
|
| 104 |
+
"start": "10:00",
|
| 105 |
+
"end": "10:30",
|
| 106 |
+
"title": "Weird",
|
| 107 |
+
"kind": "embassy_run",
|
| 108 |
+
"priority": "P1",
|
| 109 |
+
}
|
| 110 |
+
]
|
| 111 |
+
}
|
| 112 |
+
put, _, warnings = chat_plan_to_day_put(chat, "2026-07-24")
|
| 113 |
+
assert put.blocks[0].kind == "other"
|
| 114 |
+
assert "[kind:embassy_run]" in put.blocks[0].notes
|
| 115 |
+
assert any("embassy_run" in w for w in warnings)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_overlap_validation() -> None:
|
| 119 |
+
chat = {
|
| 120 |
+
"blocks": [
|
| 121 |
+
{"start": "09:00", "end": "10:00", "title": "A", "kind": "other"},
|
| 122 |
+
{"start": "09:30", "end": "10:30", "title": "B", "kind": "other"},
|
| 123 |
+
]
|
| 124 |
+
}
|
| 125 |
+
try:
|
| 126 |
+
chat_plan_to_day_put(chat, "2026-07-24")
|
| 127 |
+
assert False
|
| 128 |
+
except ChatPlanError as exc:
|
| 129 |
+
assert any("overlap" in e for e in exc.errors)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_export_round_trip_fields() -> None:
|
| 133 |
+
put, _, _ = chat_plan_to_day_put(SAMPLE, "2026-07-24")
|
| 134 |
+
plan = DayPlan(
|
| 135 |
+
date="2026-07-24",
|
| 136 |
+
blocks=put.blocks,
|
| 137 |
+
title=put.title or "",
|
| 138 |
+
intention=put.intention or "",
|
| 139 |
+
constraints=put.constraints or [],
|
| 140 |
+
day_review=put.day_review or DayReview(),
|
| 141 |
+
source="chat",
|
| 142 |
+
)
|
| 143 |
+
# Annotate first block
|
| 144 |
+
fb = {
|
| 145 |
+
put.blocks[0].id: {
|
| 146 |
+
"block_id": put.blocks[0].id,
|
| 147 |
+
"did": "done",
|
| 148 |
+
"actual_min": 20,
|
| 149 |
+
"note": "walked",
|
| 150 |
+
"emotions": ["calm"],
|
| 151 |
+
"fse_event": "",
|
| 152 |
+
"intensity": 3,
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
plan.blocks[0] = plan.blocks[0].model_copy(update={"status": "done"})
|
| 156 |
+
out = plan_and_feedback_to_chat_plan(plan, fb)
|
| 157 |
+
assert out["title"] == "Spain pack day"
|
| 158 |
+
assert out["intention"].startswith("AXA")
|
| 159 |
+
assert out["blocks"][0]["review"]["comment"] == "walked"
|
| 160 |
+
assert out["blocks"][0]["review"]["emotions"] == ["calm"]
|
| 161 |
+
assert out["summary"]["blocks_total"] == 2
|
| 162 |
+
assert out["summary"]["blocks_done"] == 1
|
| 163 |
+
assert out["summary"]["p0_total"] == 2
|
| 164 |
+
assert out["summary"]["p0_done"] == 1
|
| 165 |
+
assert out["summary"]["actual_min_sum"] == 20
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_old_plan_without_day_review_loads() -> None:
|
| 169 |
+
plan = DayPlan.model_validate(
|
| 170 |
+
{
|
| 171 |
+
"date": "2026-01-01",
|
| 172 |
+
"blocks": [],
|
| 173 |
+
"source": "user",
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
assert plan.day_review.comment == ""
|
| 177 |
+
assert plan.title == ""
|
| 178 |
+
assert plan.constraints == []
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_review_did_sets_status() -> None:
|
| 182 |
+
chat = {
|
| 183 |
+
"blocks": [
|
| 184 |
+
{
|
| 185 |
+
"start": "11:00",
|
| 186 |
+
"end": "11:30",
|
| 187 |
+
"title": "Done already",
|
| 188 |
+
"kind": "other",
|
| 189 |
+
"status": "planned",
|
| 190 |
+
"review": {"did": "done", "minutes": 25, "comment": "ok", "emotions": ["hope"]},
|
| 191 |
+
}
|
| 192 |
+
]
|
| 193 |
+
}
|
| 194 |
+
put, fbs, _ = chat_plan_to_day_put(chat, "2026-07-24")
|
| 195 |
+
assert put.blocks[0].status == "done"
|
| 196 |
+
assert len(fbs) == 1
|
| 197 |
+
assert fbs[0].emotions == ["hope"]
|
| 198 |
+
assert fbs[0].actual_min == 25
|