Mbonea Cursor commited on
Commit
fb29daa
·
1 Parent(s): 57ed4c2

Add ChatPlan paste/export loop with check-off and FSE annotations.

Browse files
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 BlockCreate, BlockFeedback, BlockPatch, DayPlanPut
 
 
 
 
 
 
 
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 ("openrouter", "rules", "cursor"):
 
 
 
 
 
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.save_plan(
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.save_plan(
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.save_plan(
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 || 30);
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
- {(["yes", "maybe", "no"] as const).map((v) => (
321
  <Pressable
322
- key={v}
323
  className="segment"
324
- ariaPressed={wouldRepeat === v}
325
- onClick={() => setWouldRepeat(v)}
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
- {["time", "fear", "urge", "boring", "other"].map((v) => (
338
  <Pressable
339
  key={v}
340
  className="segment"
341
- ariaPressed={skipReason === v}
342
- onClick={() => setSkipReason(v)}
343
  >
344
  {v}
345
  </Pressable>
346
  ))}
347
  </div>
 
 
 
 
 
 
 
 
 
348
  </>
349
  )}
350
- <label class="field-label">
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 feedback"}
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
- <p class="muted">No upcoming blocks. Open Plan to add one.</p>
 
 
 
 
 
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">{b.title}</span>
 
 
 
 
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 view: iOS-like timeline, seed, reschedule, add/complete blocks.
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
- setPlan(await getPlan(date));
 
 
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. Seed a light starter day, or add one block.</p>
152
- <Button disabled={busy} onClick={onSeed}>
 
 
 
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
- <section class="surface-card timeline-card">
161
- <Timeline blocks={plan!.blocks} onSelect={openBlock} />
162
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-kJxQGPW0.js"></script>
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-kJxQGPW0.js","/assets/index-kJxQGPW0.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 => {
 
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