Mbonea Cursor commited on
Commit
4c23c1f
·
1 Parent(s): 9af2b22

Add loop-break daily fields, proof wizard, and high-intensity coach interrupts.

Browse files
.gitignore CHANGED
@@ -9,3 +9,4 @@ frontend/dist/
9
  *.log
10
  commands.md
11
  _tmp_*.txt
 
 
9
  *.log
10
  commands.md
11
  _tmp_*.txt
12
+ handoff/
app/backup_replies.py CHANGED
@@ -25,6 +25,36 @@ RULES: list[dict[str, Any]] = [
25
  "DATA_THIN: {data_thin}"
26
  ),
27
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  {
29
  "id": "court",
30
  "priority": 90,
@@ -233,17 +263,39 @@ RULES: list[dict[str, Any]] = [
233
 
234
 
235
  DEFAULT_PICK = "Do one listed brick: water, admin item, or tidy act"
 
 
 
 
236
 
237
 
238
  def select_backup_rule(
239
  text: str,
240
  tags: list[str],
 
 
241
  ) -> dict[str, Any]:
242
- """Return the highest-priority rule matching keywords or tags."""
 
 
 
243
 
244
  haystack = (text or "").lower()
245
  tag_set = {t.strip().lower() for t in tags if t and t.strip()}
246
  ordered = sorted(RULES, key=lambda rule: rule["priority"], reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  for rule in ordered:
248
  keywords = rule.get("keywords") or []
249
  rule_tags = {t.lower() for t in (rule.get("tags") or [])}
@@ -254,15 +306,27 @@ def select_backup_rule(
254
  return ordered[-1]
255
 
256
 
 
 
 
 
 
 
 
257
  def render_backup(
258
  rule: dict[str, Any],
259
  *,
260
  server_picks: list[dict[str, Any]],
261
  data_thin: bool,
 
262
  ) -> str:
263
  """Fill the selected rule template with pick and DATA_THIN."""
264
 
265
  pick = server_picks[0]["remedy_key"] if server_picks else DEFAULT_PICK
 
 
 
 
266
  if rule["id"] == "crisis":
267
  text = rule["template"]
268
  else:
@@ -276,8 +340,17 @@ def backup_reply(
276
  server_picks: list[dict[str, Any]],
277
  *,
278
  data_thin: bool,
 
279
  ) -> tuple[str, str]:
280
  """Select and render a backup reply; return (text, rule_id)."""
281
 
282
- rule = select_backup_rule(text, tags)
283
- return render_backup(rule, server_picks=server_picks, data_thin=data_thin), rule["id"]
 
 
 
 
 
 
 
 
 
25
  "DATA_THIN: {data_thin}"
26
  ),
27
  },
28
+ {
29
+ "id": "high_intensity_fse",
30
+ "priority": 95,
31
+ "keywords": [
32
+ "shame",
33
+ "freeze",
34
+ "rerun",
35
+ "fantasy",
36
+ "daydream",
37
+ "movie in my head",
38
+ "stuck indoors",
39
+ ],
40
+ "tags": [
41
+ "shame",
42
+ "freeze",
43
+ "fantasy",
44
+ "indoors",
45
+ ],
46
+ "template": (
47
+ "LOOP: high_fse\n"
48
+ "FEELING: spike\n"
49
+ "INTENSITY_GUESS: high\n"
50
+ "REMEDY: {{server_pick}}\n"
51
+ "NEXT_BRICK: B\n"
52
+ "DO_NOT: Label more or feed the movie with cinematic headphones\n"
53
+ "LINE: Leave the room; one proof step\n"
54
+ "NOTE: Interrupt walk without movie music, or one admin line\n"
55
+ "DATA_THIN: {data_thin}"
56
+ ),
57
+ },
58
  {
59
  "id": "court",
60
  "priority": 90,
 
263
 
264
 
265
  DEFAULT_PICK = "Do one listed brick: water, admin item, or tidy act"
266
+ INTERRUPT_PICK = (
267
+ "Leave the room, cold water on face, or a 10-min interrupt walk "
268
+ "without cinematic headphones — then one proof line (admin/message)"
269
+ )
270
 
271
 
272
  def select_backup_rule(
273
  text: str,
274
  tags: list[str],
275
+ *,
276
+ intensity: int | None = None,
277
  ) -> dict[str, Any]:
278
+ """Return the highest-priority rule matching keywords or tags.
279
+
280
+ Intensity ≥ 7 forces the high_intensity_fse rule (unless crisis matches).
281
+ """
282
 
283
  haystack = (text or "").lower()
284
  tag_set = {t.strip().lower() for t in tags if t and t.strip()}
285
  ordered = sorted(RULES, key=lambda rule: rule["priority"], reverse=True)
286
+
287
+ crisis = next((r for r in ordered if r["id"] == "crisis"), None)
288
+ if crisis:
289
+ keywords = crisis.get("keywords") or []
290
+ rule_tags = {t.lower() for t in (crisis.get("tags") or [])}
291
+ if any(keyword in haystack for keyword in keywords) or (tag_set & rule_tags):
292
+ return crisis
293
+
294
+ if intensity is not None and intensity >= 7:
295
+ high = next((r for r in ordered if r["id"] == "high_intensity_fse"), None)
296
+ if high:
297
+ return high
298
+
299
  for rule in ordered:
300
  keywords = rule.get("keywords") or []
301
  rule_tags = {t.lower() for t in (rule.get("tags") or [])}
 
306
  return ordered[-1]
307
 
308
 
309
+ def _sanitize_pick(pick: str, *, force_interrupt: bool) -> str:
310
+ lowered = (pick or "").strip().lower()
311
+ if force_interrupt or "label" in lowered:
312
+ return INTERRUPT_PICK
313
+ return pick or DEFAULT_PICK
314
+
315
+
316
  def render_backup(
317
  rule: dict[str, Any],
318
  *,
319
  server_picks: list[dict[str, Any]],
320
  data_thin: bool,
321
+ intensity: int | None = None,
322
  ) -> str:
323
  """Fill the selected rule template with pick and DATA_THIN."""
324
 
325
  pick = server_picks[0]["remedy_key"] if server_picks else DEFAULT_PICK
326
+ force_interrupt = rule["id"] == "high_intensity_fse" or (
327
+ intensity is not None and intensity >= 7
328
+ )
329
+ pick = _sanitize_pick(pick, force_interrupt=force_interrupt)
330
  if rule["id"] == "crisis":
331
  text = rule["template"]
332
  else:
 
340
  server_picks: list[dict[str, Any]],
341
  *,
342
  data_thin: bool,
343
+ intensity: int | None = None,
344
  ) -> tuple[str, str]:
345
  """Select and render a backup reply; return (text, rule_id)."""
346
 
347
+ rule = select_backup_rule(text, tags, intensity=intensity)
348
+ return (
349
+ render_backup(
350
+ rule,
351
+ server_picks=server_picks,
352
+ data_thin=data_thin,
353
+ intensity=intensity,
354
+ ),
355
+ rule["id"],
356
+ )
app/coach_service.py CHANGED
@@ -246,6 +246,7 @@ class CoachService:
246
  "remedy": entry.remedy if entry else None,
247
  "result": entry.result.value if entry else None,
248
  }
 
249
 
250
  history_k = (
251
  self.settings.coach_history_k
@@ -301,6 +302,7 @@ class CoachService:
301
  current_tags + current_emotions,
302
  picks,
303
  data_thin=evidence["DATA_THIN"],
 
304
  )
305
  source = "backup"
306
  parsed = parse_template(final_text)
@@ -321,6 +323,17 @@ class CoachService:
321
  key in remedy or remedy in key for key in pick_keys
322
  ):
323
  flags.append("OUT_OF_EVIDENCE")
 
 
 
 
 
 
 
 
 
 
 
324
  else:
325
  flags.append("PARSE_FAIL")
326
  source = "model_unparsed_fallback"
@@ -329,6 +342,7 @@ class CoachService:
329
  current_tags + current_emotions,
330
  picks,
331
  data_thin=evidence["DATA_THIN"],
 
332
  )
333
  parsed = parse_template(final_text)
334
  else:
@@ -337,6 +351,7 @@ class CoachService:
337
  current_tags + current_emotions,
338
  picks,
339
  data_thin=evidence["DATA_THIN"],
 
340
  )
341
  source = "backup"
342
  parsed = parse_template(final_text)
 
246
  "remedy": entry.remedy if entry else None,
247
  "result": entry.result.value if entry else None,
248
  }
249
+ intensity = entry.intensity if entry else None
250
 
251
  history_k = (
252
  self.settings.coach_history_k
 
302
  current_tags + current_emotions,
303
  picks,
304
  data_thin=evidence["DATA_THIN"],
305
+ intensity=intensity,
306
  )
307
  source = "backup"
308
  parsed = parse_template(final_text)
 
323
  key in remedy or remedy in key for key in pick_keys
324
  ):
325
  flags.append("OUT_OF_EVIDENCE")
326
+ if intensity is not None and intensity >= 7 and "label" in remedy:
327
+ flags.append("LABEL_AT_HIGH_INTENSITY")
328
+ final_text, backup_rule_id = backup_reply(
329
+ current_text,
330
+ current_tags + current_emotions,
331
+ picks,
332
+ data_thin=evidence["DATA_THIN"],
333
+ intensity=intensity,
334
+ )
335
+ source = "backup"
336
+ parsed = parse_template(final_text)
337
  else:
338
  flags.append("PARSE_FAIL")
339
  source = "model_unparsed_fallback"
 
342
  current_tags + current_emotions,
343
  picks,
344
  data_thin=evidence["DATA_THIN"],
345
+ intensity=intensity,
346
  )
347
  parsed = parse_template(final_text)
348
  else:
 
351
  current_tags + current_emotions,
352
  picks,
353
  data_thin=evidence["DATA_THIN"],
354
+ intensity=intensity,
355
  )
356
  source = "backup"
357
  parsed = parse_template(final_text)
app/models.py CHANGED
@@ -283,6 +283,16 @@ class Court(str, Enum):
283
  court = "court"
284
 
285
 
 
 
 
 
 
 
 
 
 
 
286
  class DailyUpsert(BaseModel):
287
  """Client payload for upserting a daily scoreboard row."""
288
 
@@ -295,6 +305,18 @@ class DailyUpsert(BaseModel):
295
  daydream: Daydream = Daydream.none
296
  rerun: Rerun = Rerun.clean
297
  court: Court = Court.closed
 
 
 
 
 
 
 
 
 
 
 
 
298
  note: str = Field(default="", max_length=2000)
299
 
300
  @field_validator("note", mode="after")
@@ -316,6 +338,18 @@ class DailyRow(BaseModel):
316
  daydream: Daydream = Daydream.none
317
  rerun: Rerun = Rerun.clean
318
  court: Court = Court.closed
 
 
 
 
 
 
 
 
 
 
 
 
319
  points: int = Field(ge=0, le=6)
320
  note: str = ""
321
  updated_at: datetime
 
283
  court = "court"
284
 
285
 
286
+ ProofBrickKind = Literal[
287
+ "leave_room",
288
+ "admin_line",
289
+ "send_message",
290
+ "interrupt_walk",
291
+ "body_care",
292
+ "other",
293
+ ]
294
+
295
+
296
  class DailyUpsert(BaseModel):
297
  """Client payload for upserting a daily scoreboard row."""
298
 
 
305
  daydream: Daydream = Daydream.none
306
  rerun: Rerun = Rerun.clean
307
  court: Court = Court.closed
308
+ stayed_indoors_all_day: bool = False
309
+ left_room: bool = False
310
+ left_home: bool = False
311
+ movement_minutes: int = Field(default=0, ge=0, le=24 * 60)
312
+ interrupt_walk_minutes: int = Field(default=0, ge=0, le=24 * 60)
313
+ fantasy_walk_minutes: int = Field(default=0, ge=0, le=24 * 60)
314
+ headphones_on_walk: bool = False
315
+ music_cinematic_on_walk: bool = False
316
+ proof_brick_done: bool = False
317
+ proof_brick_kind: ProofBrickKind | None = None
318
+ fantasy_minutes_scheduled: int = Field(default=0, ge=0, le=24 * 60)
319
+ fantasy_minutes_unplanned: int = Field(default=0, ge=0, le=24 * 60)
320
  note: str = Field(default="", max_length=2000)
321
 
322
  @field_validator("note", mode="after")
 
338
  daydream: Daydream = Daydream.none
339
  rerun: Rerun = Rerun.clean
340
  court: Court = Court.closed
341
+ stayed_indoors_all_day: bool = False
342
+ left_room: bool = False
343
+ left_home: bool = False
344
+ movement_minutes: int = Field(default=0, ge=0, le=24 * 60)
345
+ interrupt_walk_minutes: int = Field(default=0, ge=0, le=24 * 60)
346
+ fantasy_walk_minutes: int = Field(default=0, ge=0, le=24 * 60)
347
+ headphones_on_walk: bool = False
348
+ music_cinematic_on_walk: bool = False
349
+ proof_brick_done: bool = False
350
+ proof_brick_kind: ProofBrickKind | None = None
351
+ fantasy_minutes_scheduled: int = Field(default=0, ge=0, le=24 * 60)
352
+ fantasy_minutes_unplanned: int = Field(default=0, ge=0, le=24 * 60)
353
  points: int = Field(ge=0, le=6)
354
  note: str = ""
355
  updated_at: datetime
app/routers/agent.py CHANGED
@@ -94,6 +94,7 @@ def agent_context(
94
  priors = store.load_priors()
95
  y_daily = daily_store.get(target - timedelta(days=1))
96
  t_daily = daily_store.get(target)
 
97
 
98
  return ok(
99
  {
@@ -102,6 +103,8 @@ def agent_context(
102
  "now": now.isoformat(),
103
  "daily_yesterday": y_daily.model_dump(mode="json") if y_daily else None,
104
  "daily_today": t_daily.model_dump(mode="json") if t_daily else None,
 
 
105
  "entries_last_48h": entries_48h,
106
  "risk_1h": {"score": risk_score, "tags": tags_1h, "high": last_hour_high},
107
  "triggers_yesterday": triggers_y,
 
94
  priors = store.load_priors()
95
  y_daily = daily_store.get(target - timedelta(days=1))
96
  t_daily = daily_store.get(target)
97
+ movement = daily_store.movement_for(target)
98
 
99
  return ok(
100
  {
 
103
  "now": now.isoformat(),
104
  "daily_yesterday": y_daily.model_dump(mode="json") if y_daily else None,
105
  "daily_today": t_daily.model_dump(mode="json") if t_daily else None,
106
+ "movement": movement,
107
+ "indoors_streak": movement["indoors_streak"],
108
  "entries_last_48h": entries_48h,
109
  "risk_1h": {"score": risk_score, "tags": tags_1h, "high": last_hour_high},
110
  "triggers_yesterday": triggers_y,
app/schedule_templates.py CHANGED
@@ -170,6 +170,18 @@ TEMPLATES: list[dict[str, Any]] = [
170
  "label": "Sleep wind-down",
171
  "locked": True,
172
  },
 
 
 
 
 
 
 
 
 
 
 
 
173
  ]
174
 
175
 
 
170
  "label": "Sleep wind-down",
171
  "locked": True,
172
  },
173
+ {
174
+ "id": "fantasy_slot_25",
175
+ "title": "Scheduled movie/fantasy (timer)",
176
+ "kind": "restore_fun",
177
+ "intent": "restore_fun",
178
+ "priority": "P2",
179
+ "default_min": 25,
180
+ "utility": 0.35,
181
+ "fse_load": 0.15,
182
+ "label": "Fun recharge",
183
+ "notes": "Containment slot — timer on; not a lecture. After timer, one proof brick.",
184
+ },
185
  ]
186
 
187
 
app/store_daily.py CHANGED
@@ -3,7 +3,10 @@
3
  Rows are unique by date; upserts rewrite the JSONL file under lock.
4
  """
5
 
6
- from datetime import date, datetime, timezone
 
 
 
7
 
8
  from app.fsutil import read_jsonl, rewrite_jsonl
9
  from app.models import Court, DailyRow, DailyUpsert, Daydream, Rerun
@@ -50,6 +53,80 @@ def week_band(days_present: int, week_points: int) -> str:
50
  return "escape_heavy"
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def _utc_now() -> datetime:
54
  return datetime.now(timezone.utc)
55
 
@@ -82,6 +159,7 @@ class DailyStore:
82
  rerun=data.rerun,
83
  court=data.court,
84
  )
 
85
  row = DailyRow(
86
  date=day,
87
  primary_brick=data.primary_brick,
@@ -91,6 +169,18 @@ class DailyStore:
91
  daydream=data.daydream,
92
  rerun=data.rerun,
93
  court=data.court,
 
 
 
 
 
 
 
 
 
 
 
 
94
  points=points,
95
  note=data.note,
96
  updated_at=_utc_now(),
@@ -118,3 +208,8 @@ class DailyStore:
118
  week_points = sum(row.points for row in items)
119
  band = week_band(days_present, week_points)
120
  return items, week_points, band, days_present
 
 
 
 
 
 
3
  Rows are unique by date; upserts rewrite the JSONL file under lock.
4
  """
5
 
6
+ from __future__ import annotations
7
+
8
+ from datetime import date, datetime, timedelta, timezone
9
+ from typing import Any
10
 
11
  from app.fsutil import read_jsonl, rewrite_jsonl
12
  from app.models import Court, DailyRow, DailyUpsert, Daydream, Rerun
 
53
  return "escape_heavy"
54
 
55
 
56
+ def indoors_streak(rows: list[DailyRow], *, as_of: date) -> int:
57
+ """Count consecutive stayed_indoors_all_day days ending at as_of."""
58
+
59
+ by_date = {row.date: row for row in rows}
60
+ streak = 0
61
+ day = as_of
62
+ while True:
63
+ row = by_date.get(day)
64
+ if row is None or not row.stayed_indoors_all_day:
65
+ break
66
+ streak += 1
67
+ day = day - timedelta(days=1)
68
+ return streak
69
+
70
+
71
+ def resolve_movement_minutes(data: DailyUpsert) -> int:
72
+ """Prefer sum of walk splits when present; else client movement_minutes."""
73
+
74
+ split = int(data.interrupt_walk_minutes or 0) + int(data.fantasy_walk_minutes or 0)
75
+ if split > 0:
76
+ return min(24 * 60, split)
77
+ return int(data.movement_minutes or 0)
78
+
79
+
80
+ def movement_summary(rows: list[DailyRow], *, as_of: date, lookback: int = 3) -> dict[str, Any]:
81
+ """Indoors streak + walk/proof/fantasy snapshot for agent / Home nudges."""
82
+
83
+ by_date = {row.date: row for row in rows}
84
+ last_left: list[dict[str, Any]] = []
85
+ for offset in range(lookback):
86
+ day = as_of - timedelta(days=offset)
87
+ row = by_date.get(day)
88
+ last_left.append(
89
+ {
90
+ "date": day.isoformat(),
91
+ "left_home": bool(row.left_home) if row else None,
92
+ "left_room": bool(row.left_room) if row else None,
93
+ "stayed_indoors_all_day": bool(row.stayed_indoors_all_day) if row else None,
94
+ "movement_minutes": int(row.movement_minutes) if row else None,
95
+ "interrupt_walk_minutes": int(row.interrupt_walk_minutes) if row else None,
96
+ "fantasy_walk_minutes": int(row.fantasy_walk_minutes) if row else None,
97
+ "headphones_on_walk": bool(row.headphones_on_walk) if row else None,
98
+ "music_cinematic_on_walk": bool(row.music_cinematic_on_walk) if row else None,
99
+ "proof_brick_done": bool(row.proof_brick_done) if row else None,
100
+ "proof_brick_kind": row.proof_brick_kind if row else None,
101
+ "fantasy_minutes_scheduled": int(row.fantasy_minutes_scheduled) if row else None,
102
+ "fantasy_minutes_unplanned": int(row.fantasy_minutes_unplanned) if row else None,
103
+ }
104
+ )
105
+ today = by_date.get(as_of)
106
+ streak = indoors_streak(rows, as_of=as_of)
107
+ return {
108
+ "indoors_streak": streak,
109
+ "nudge": streak >= 2,
110
+ "last_3_days_left_home": last_left,
111
+ "today": (
112
+ {
113
+ "left_room": today.left_room,
114
+ "left_home": today.left_home,
115
+ "interrupt_walk_minutes": today.interrupt_walk_minutes,
116
+ "fantasy_walk_minutes": today.fantasy_walk_minutes,
117
+ "headphones_on_walk": today.headphones_on_walk,
118
+ "music_cinematic_on_walk": today.music_cinematic_on_walk,
119
+ "proof_brick_done": today.proof_brick_done,
120
+ "proof_brick_kind": today.proof_brick_kind,
121
+ "fantasy_minutes_scheduled": today.fantasy_minutes_scheduled,
122
+ "fantasy_minutes_unplanned": today.fantasy_minutes_unplanned,
123
+ }
124
+ if today
125
+ else None
126
+ ),
127
+ }
128
+
129
+
130
  def _utc_now() -> datetime:
131
  return datetime.now(timezone.utc)
132
 
 
159
  rerun=data.rerun,
160
  court=data.court,
161
  )
162
+ movement = resolve_movement_minutes(data)
163
  row = DailyRow(
164
  date=day,
165
  primary_brick=data.primary_brick,
 
169
  daydream=data.daydream,
170
  rerun=data.rerun,
171
  court=data.court,
172
+ stayed_indoors_all_day=data.stayed_indoors_all_day,
173
+ left_room=data.left_room,
174
+ left_home=data.left_home,
175
+ movement_minutes=movement,
176
+ interrupt_walk_minutes=data.interrupt_walk_minutes,
177
+ fantasy_walk_minutes=data.fantasy_walk_minutes,
178
+ headphones_on_walk=data.headphones_on_walk,
179
+ music_cinematic_on_walk=data.music_cinematic_on_walk,
180
+ proof_brick_done=data.proof_brick_done,
181
+ proof_brick_kind=data.proof_brick_kind,
182
+ fantasy_minutes_scheduled=data.fantasy_minutes_scheduled,
183
+ fantasy_minutes_unplanned=data.fantasy_minutes_unplanned,
184
  points=points,
185
  note=data.note,
186
  updated_at=_utc_now(),
 
208
  week_points = sum(row.points for row in items)
209
  band = week_band(days_present, week_points)
210
  return items, week_points, band, days_present
211
+
212
+ def movement_for(self, as_of: date) -> dict[str, Any]:
213
+ """Movement / indoors summary ending at as_of."""
214
+
215
+ return movement_summary(self._load(), as_of=as_of)
frontend/src/api.ts CHANGED
@@ -57,6 +57,14 @@ export type EntryListData = {
57
 
58
  export type PrimaryBrick = "A" | "B" | "C" | "D" | "E" | "S" | "none";
59
 
 
 
 
 
 
 
 
 
60
  export type DailyRow = {
61
  date: string;
62
  primary_brick: PrimaryBrick;
@@ -66,6 +74,18 @@ export type DailyRow = {
66
  daydream: "none" | "done" | "fc";
67
  rerun: "clean" | "R";
68
  court: "closed" | "court";
 
 
 
 
 
 
 
 
 
 
 
 
69
  points: number;
70
  note: string;
71
  updated_at: string;
 
57
 
58
  export type PrimaryBrick = "A" | "B" | "C" | "D" | "E" | "S" | "none";
59
 
60
+ export type ProofBrickKind =
61
+ | "leave_room"
62
+ | "admin_line"
63
+ | "send_message"
64
+ | "interrupt_walk"
65
+ | "body_care"
66
+ | "other";
67
+
68
  export type DailyRow = {
69
  date: string;
70
  primary_brick: PrimaryBrick;
 
74
  daydream: "none" | "done" | "fc";
75
  rerun: "clean" | "R";
76
  court: "closed" | "court";
77
+ stayed_indoors_all_day: boolean;
78
+ left_room: boolean;
79
+ left_home: boolean;
80
+ movement_minutes: number;
81
+ interrupt_walk_minutes: number;
82
+ fantasy_walk_minutes: number;
83
+ headphones_on_walk: boolean;
84
+ music_cinematic_on_walk: boolean;
85
+ proof_brick_done: boolean;
86
+ proof_brick_kind: ProofBrickKind | null;
87
+ fantasy_minutes_scheduled: number;
88
+ fantasy_minutes_unplanned: number;
89
  points: number;
90
  note: string;
91
  updated_at: string;
frontend/src/pages/Daily.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
  type DailyRow,
9
  type DailyUpsert,
10
  type PrimaryBrick,
 
11
  } from "../api";
12
  import { Button } from "../components/Button";
13
  import { Pressable } from "../components/Pressable";
@@ -22,9 +23,30 @@ const EMPTY: DailyUpsert = {
22
  daydream: "none",
23
  rerun: "clean",
24
  court: "closed",
 
 
 
 
 
 
 
 
 
 
 
 
25
  note: "",
26
  };
27
 
 
 
 
 
 
 
 
 
 
28
  const BRICK_OPTIONS: { value: PrimaryBrick; label: string }[] = [
29
  { value: "A", label: "Admin / trip task" },
30
  { value: "B", label: "Body or room care" },
@@ -158,6 +180,18 @@ export function Daily() {
158
  daydream: row.daydream,
159
  rerun: row.rerun,
160
  court: row.court,
 
 
 
 
 
 
 
 
 
 
 
 
161
  note: row.note,
162
  });
163
  setSaved(row);
@@ -297,6 +331,181 @@ export function Daily() {
297
  />
298
  </section>
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  <label class="field-label">
301
  Note (optional)
302
  <input
 
8
  type DailyRow,
9
  type DailyUpsert,
10
  type PrimaryBrick,
11
+ type ProofBrickKind,
12
  } from "../api";
13
  import { Button } from "../components/Button";
14
  import { Pressable } from "../components/Pressable";
 
23
  daydream: "none",
24
  rerun: "clean",
25
  court: "closed",
26
+ stayed_indoors_all_day: false,
27
+ left_room: false,
28
+ left_home: false,
29
+ movement_minutes: 0,
30
+ interrupt_walk_minutes: 0,
31
+ fantasy_walk_minutes: 0,
32
+ headphones_on_walk: false,
33
+ music_cinematic_on_walk: false,
34
+ proof_brick_done: false,
35
+ proof_brick_kind: null,
36
+ fantasy_minutes_scheduled: 0,
37
+ fantasy_minutes_unplanned: 0,
38
  note: "",
39
  };
40
 
41
+ const PROOF_KINDS: { value: ProofBrickKind; label: string }[] = [
42
+ { value: "leave_room", label: "Left the room" },
43
+ { value: "admin_line", label: "One admin / trip line" },
44
+ { value: "send_message", label: "Sent a real message" },
45
+ { value: "interrupt_walk", label: "Interrupt walk" },
46
+ { value: "body_care", label: "Body / room care" },
47
+ { value: "other", label: "Other proof" },
48
+ ];
49
+
50
  const BRICK_OPTIONS: { value: PrimaryBrick; label: string }[] = [
51
  { value: "A", label: "Admin / trip task" },
52
  { value: "B", label: "Body or room care" },
 
180
  daydream: row.daydream,
181
  rerun: row.rerun,
182
  court: row.court,
183
+ stayed_indoors_all_day: Boolean(row.stayed_indoors_all_day),
184
+ left_room: Boolean(row.left_room),
185
+ left_home: Boolean(row.left_home),
186
+ movement_minutes: row.movement_minutes ?? 0,
187
+ interrupt_walk_minutes: row.interrupt_walk_minutes ?? 0,
188
+ fantasy_walk_minutes: row.fantasy_walk_minutes ?? 0,
189
+ headphones_on_walk: Boolean(row.headphones_on_walk),
190
+ music_cinematic_on_walk: Boolean(row.music_cinematic_on_walk),
191
+ proof_brick_done: Boolean(row.proof_brick_done),
192
+ proof_brick_kind: row.proof_brick_kind ?? null,
193
+ fantasy_minutes_scheduled: row.fantasy_minutes_scheduled ?? 0,
194
+ fantasy_minutes_unplanned: row.fantasy_minutes_unplanned ?? 0,
195
  note: row.note,
196
  });
197
  setSaved(row);
 
331
  />
332
  </section>
333
 
334
+ <section class="surface-card form-card stack">
335
+ <span class="section-title">Movement</span>
336
+ <span class="field-label">Did you stay indoors the whole day?</span>
337
+ <ChoiceList
338
+ options={[
339
+ { value: "yes", label: "Yes" },
340
+ { value: "no", label: "No" },
341
+ ]}
342
+ value={form.stayed_indoors_all_day ? "yes" : "no"}
343
+ onChange={(v) =>
344
+ setForm({ ...form, stayed_indoors_all_day: v === "yes" })
345
+ }
346
+ />
347
+ <span class="field-label">Did you leave the bedroom / room?</span>
348
+ <ChoiceList
349
+ options={[
350
+ { value: "yes", label: "Yes" },
351
+ { value: "no", label: "No" },
352
+ ]}
353
+ value={form.left_room ? "yes" : "no"}
354
+ onChange={(v) => setForm({ ...form, left_room: v === "yes" })}
355
+ />
356
+ <span class="field-label">Did you leave the house/compound?</span>
357
+ <ChoiceList
358
+ options={[
359
+ { value: "yes", label: "Yes" },
360
+ { value: "no", label: "No" },
361
+ ]}
362
+ value={form.left_home ? "yes" : "no"}
363
+ onChange={(v) => setForm({ ...form, left_home: v === "yes" })}
364
+ />
365
+ <label class="field-label">
366
+ Interrupt walk minutes (attention break — better without headphones)
367
+ <input
368
+ class="field-input"
369
+ type="number"
370
+ min={0}
371
+ max={24 * 60}
372
+ value={form.interrupt_walk_minutes}
373
+ onInput={(e) => {
374
+ const interrupt_walk_minutes = Math.max(
375
+ 0,
376
+ Number((e.target as HTMLInputElement).value) || 0,
377
+ );
378
+ setForm({
379
+ ...form,
380
+ interrupt_walk_minutes,
381
+ movement_minutes:
382
+ interrupt_walk_minutes + form.fantasy_walk_minutes,
383
+ });
384
+ }}
385
+ />
386
+ </label>
387
+ <label class="field-label">
388
+ Fantasy / music walk minutes (honest log OK)
389
+ <input
390
+ class="field-input"
391
+ type="number"
392
+ min={0}
393
+ max={24 * 60}
394
+ value={form.fantasy_walk_minutes}
395
+ onInput={(e) => {
396
+ const fantasy_walk_minutes = Math.max(
397
+ 0,
398
+ Number((e.target as HTMLInputElement).value) || 0,
399
+ );
400
+ setForm({
401
+ ...form,
402
+ fantasy_walk_minutes,
403
+ movement_minutes:
404
+ form.interrupt_walk_minutes + fantasy_walk_minutes,
405
+ });
406
+ }}
407
+ />
408
+ </label>
409
+ <span class="field-label">Headphones on a walk?</span>
410
+ <ChoiceList
411
+ options={[
412
+ { value: "yes", label: "Yes" },
413
+ { value: "no", label: "No" },
414
+ ]}
415
+ value={form.headphones_on_walk ? "yes" : "no"}
416
+ onChange={(v) => setForm({ ...form, headphones_on_walk: v === "yes" })}
417
+ />
418
+ <span class="field-label">Cinematic / movie music on a walk?</span>
419
+ <ChoiceList
420
+ options={[
421
+ { value: "yes", label: "Yes" },
422
+ { value: "no", label: "No" },
423
+ ]}
424
+ value={form.music_cinematic_on_walk ? "yes" : "no"}
425
+ onChange={(v) =>
426
+ setForm({ ...form, music_cinematic_on_walk: v === "yes" })
427
+ }
428
+ />
429
+ <p class="caption muted">
430
+ Compound laps and short walks count — no spend needed. Total moving:{" "}
431
+ {form.interrupt_walk_minutes + form.fantasy_walk_minutes}m
432
+ </p>
433
+ </section>
434
+
435
+ <section class="surface-card form-card stack">
436
+ <span class="section-title">Proof brick</span>
437
+ <span class="field-label">Did you do one real proof step today?</span>
438
+ <ChoiceList
439
+ options={[
440
+ { value: "yes", label: "Yes" },
441
+ { value: "no", label: "No" },
442
+ ]}
443
+ value={form.proof_brick_done ? "yes" : "no"}
444
+ onChange={(v) =>
445
+ setForm({
446
+ ...form,
447
+ proof_brick_done: v === "yes",
448
+ proof_brick_kind: v === "yes" ? form.proof_brick_kind : null,
449
+ })
450
+ }
451
+ />
452
+ {form.proof_brick_done && (
453
+ <>
454
+ <span class="field-label">What kind?</span>
455
+ <ChoiceList
456
+ options={PROOF_KINDS}
457
+ value={(form.proof_brick_kind || "other") as ProofBrickKind}
458
+ onChange={(proof_brick_kind) =>
459
+ setForm({ ...form, proof_brick_kind })
460
+ }
461
+ />
462
+ </>
463
+ )}
464
+ </section>
465
+
466
+ <section class="surface-card form-card stack">
467
+ <span class="section-title">Movie / fantasy time</span>
468
+ <label class="field-label">
469
+ Minutes in a scheduled slot
470
+ <input
471
+ class="field-input"
472
+ type="number"
473
+ min={0}
474
+ max={24 * 60}
475
+ value={form.fantasy_minutes_scheduled}
476
+ onInput={(e) =>
477
+ setForm({
478
+ ...form,
479
+ fantasy_minutes_scheduled: Math.max(
480
+ 0,
481
+ Number((e.target as HTMLInputElement).value) || 0,
482
+ ),
483
+ })
484
+ }
485
+ />
486
+ </label>
487
+ <label class="field-label">
488
+ Minutes unplanned (drifted into it)
489
+ <input
490
+ class="field-input"
491
+ type="number"
492
+ min={0}
493
+ max={24 * 60}
494
+ value={form.fantasy_minutes_unplanned}
495
+ onInput={(e) =>
496
+ setForm({
497
+ ...form,
498
+ fantasy_minutes_unplanned: Math.max(
499
+ 0,
500
+ Number((e.target as HTMLInputElement).value) || 0,
501
+ ),
502
+ })
503
+ }
504
+ />
505
+ </label>
506
+ <p class="caption muted">Containment beats guilt. Log honestly.</p>
507
+ </section>
508
+
509
  <label class="field-label">
510
  Note (optional)
511
  <input
frontend/src/pages/Home.tsx CHANGED
@@ -35,6 +35,7 @@ export function Home() {
35
  const [entryDays, setEntryDays] = useState<Set<string>>(new Set());
36
  const [nextBlocks, setNextBlocks] = useState<ScheduledBlock[]>([]);
37
  const [overdue, setOverdue] = useState<ScheduledBlock | null>(null);
 
38
 
39
  const load = async () => {
40
  const weekDays = mondaySundayWeek(new Date());
@@ -46,9 +47,26 @@ export function Home() {
46
  setPending([]);
47
  }
48
  try {
49
- setWeek(await listDaily(weekDays[0], weekDays[6]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  } catch {
51
  setWeek(null);
 
52
  }
53
  try {
54
  const recentData = await listEntries({ limit: 8 });
@@ -97,6 +115,26 @@ export function Home() {
97
  const todayPoints = todayRow?.points ?? 0;
98
  const progress = Math.min(1, weekPoints / WEEK_MAX);
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  const dayFilled = (day: string) => {
101
  const row = weekRows.get(day);
102
  return Boolean(row?.brick_done) || entryDays.has(day);
@@ -175,6 +213,41 @@ export function Home() {
175
  </Pressable>
176
  )}
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  <section class="surface-card form-card stack" aria-label="Today's plan">
179
  <div class="week-progress-row">
180
  <h2 class="section-title" style={{ margin: 0 }}>
 
35
  const [entryDays, setEntryDays] = useState<Set<string>>(new Set());
36
  const [nextBlocks, setNextBlocks] = useState<ScheduledBlock[]>([]);
37
  const [overdue, setOverdue] = useState<ScheduledBlock | null>(null);
38
+ const [indoorsStreak, setIndoorsStreak] = useState(0);
39
 
40
  const load = async () => {
41
  const weekDays = mondaySundayWeek(new Date());
 
47
  setPending([]);
48
  }
49
  try {
50
+ const weekData = await listDaily(weekDays[0], weekDays[6]);
51
+ setWeek(weekData);
52
+ const streakStart = isoDate(
53
+ new Date(new Date(`${todayIso}T00:00:00`).getTime() - 13 * 86400000),
54
+ );
55
+ const streakRange = await listDaily(streakStart, todayIso);
56
+ const byDate = new Map(streakRange.items.map((row) => [row.date, row]));
57
+ let streak = 0;
58
+ const cursor = new Date(`${todayIso}T00:00:00`);
59
+ for (let i = 0; i < 14; i += 1) {
60
+ const key = isoDate(cursor);
61
+ const row = byDate.get(key);
62
+ if (!row?.stayed_indoors_all_day) break;
63
+ streak += 1;
64
+ cursor.setDate(cursor.getDate() - 1);
65
+ }
66
+ setIndoorsStreak(streak);
67
  } catch {
68
  setWeek(null);
69
+ setIndoorsStreak(0);
70
  }
71
  try {
72
  const recentData = await listEntries({ limit: 8 });
 
115
  const todayPoints = todayRow?.points ?? 0;
116
  const progress = Math.min(1, weekPoints / WEEK_MAX);
117
 
118
+ const fseEmotions = new Set(["shame", "urge", "anxiety", "anger", "lonely", "freeze"]);
119
+ const needsProofNudge =
120
+ Boolean(todayRow) &&
121
+ !todayRow?.proof_brick_done &&
122
+ [...pending, ...recent].some((e) => {
123
+ const emos = (e.emotions || []).map((x) => x.toLowerCase());
124
+ const tags = (e.tags || []).map((x) => x.toLowerCase());
125
+ return (
126
+ (e.intensity ?? 0) >= 7 &&
127
+ (e.result === "pending" || e.result === "failed") &&
128
+ (emos.some((x) => fseEmotions.has(x)) ||
129
+ tags.some((x) => ["freeze", "indoors", "shame", "rerun"].includes(x)))
130
+ );
131
+ });
132
+
133
+ const fantasyWalkTip =
134
+ Boolean(todayRow) &&
135
+ (todayRow!.fantasy_walk_minutes ?? 0) >= 15 &&
136
+ (todayRow!.headphones_on_walk || todayRow!.music_cinematic_on_walk);
137
+
138
  const dayFilled = (day: string) => {
139
  const row = weekRows.get(day);
140
  return Boolean(row?.brick_done) || entryDays.has(day);
 
213
  </Pressable>
214
  )}
215
 
216
+ {indoorsStreak >= 2 && (
217
+ <Pressable
218
+ className="tip-card indoors-nudge"
219
+ onClick={() => navigate("/daily")}
220
+ ariaLabel={`${indoorsStreak} days indoors — take a short walk`}
221
+ >
222
+ <span class="warning-chip">{indoorsStreak} days indoors</span>
223
+ <p>
224
+ {indoorsStreak} days indoors — 20 min walk, no spend needed
225
+ </p>
226
+ </Pressable>
227
+ )}
228
+
229
+ {needsProofNudge && (
230
+ <Pressable
231
+ className="tip-card indoors-nudge"
232
+ onClick={() => navigate("/daily")}
233
+ ariaLabel="Add one proof brick on Daily"
234
+ >
235
+ <span class="warning-chip">Proof missing</span>
236
+ <p>
237
+ High spike logged — one proof brick today (not more labeling)
238
+ </p>
239
+ </Pressable>
240
+ )}
241
+
242
+ {fantasyWalkTip && (
243
+ <div class="tip-card" role="status">
244
+ <p>
245
+ Fantasy walks are fine to log — interrupt walks work better without
246
+ cinematic music
247
+ </p>
248
+ </div>
249
+ )}
250
+
251
  <section class="surface-card form-card stack" aria-label="Today's plan">
252
  <div class="week-progress-row">
253
  <h2 class="section-title" style={{ margin: 0 }}>
frontend/src/pages/Log.tsx CHANGED
@@ -1,13 +1,21 @@
1
- /** Fast Log page: save an entry; pending offers Finish now. */
2
 
3
  import { useEffect, useMemo, useState } from "preact/hooks";
4
- import { ApiError, createEntry } from "../api";
 
 
 
 
 
 
 
5
  import { Button } from "../components/Button";
6
  import { Pressable } from "../components/Pressable";
7
  import { ResultChips, type ResultValue } from "../components/ResultChips";
8
  import { Sheet } from "../components/Sheet";
9
  import { usePendingResolve } from "../components/PendingResolve";
10
  import { useToast } from "../components/Toast";
 
11
  import { navigate } from "../router";
12
 
13
  const EMOTION_PICKS = [
@@ -21,6 +29,66 @@ const EMOTION_PICKS = [
21
  "hope",
22
  ];
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
25
  const toast = useToast();
26
  const { openResolveId } = usePendingResolve();
@@ -35,6 +103,12 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
35
  const [moreOpen, setMoreOpen] = useState(false);
36
  const [saving, setSaving] = useState(false);
37
  const [finishPrompt, setFinishPrompt] = useState<{ id: string } | null>(null);
 
 
 
 
 
 
38
 
39
  useEffect(() => {
40
  setRemedy(initialRemedy);
@@ -45,6 +119,15 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
45
  [happened, saving],
46
  );
47
 
 
 
 
 
 
 
 
 
 
48
  const toggleEmotion = (emotion: string) => {
49
  setEmotions((current) =>
50
  current.includes(emotion)
@@ -55,6 +138,24 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
55
  );
56
  };
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  const onSave = async () => {
59
  if (!canSave) return;
60
  setSaving(true);
@@ -66,10 +167,7 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
66
  intensity,
67
  remedy: remedy.trim(),
68
  result,
69
- tags: tags
70
- .split(",")
71
- .map((t) => t.trim())
72
- .filter(Boolean),
73
  notes: notes.trim(),
74
  });
75
  toast.show("Saved");
@@ -80,10 +178,12 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
80
  ) {
81
  navigator.vibrate(10);
82
  }
83
- if (entry.result === "pending") {
84
- setFinishPrompt({ id: entry.id });
 
 
85
  } else {
86
- navigate("/");
87
  }
88
  } catch (err) {
89
  const message =
@@ -94,6 +194,83 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
94
  }
95
  };
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  return (
98
  <div class="app-shell">
99
  <header class="top-bar material-bar">
@@ -172,10 +349,26 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
172
  type="text"
173
  value={remedy}
174
  onInput={(e) => setRemedy((e.target as HTMLInputElement).value)}
175
- placeholder="One concrete action"
176
  />
177
  </div>
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  <Button disabled={!canSave} onClick={onSave} className="btn-large">
180
  {saving ? "Saving…" : "Save entry"}
181
  </Button>
@@ -206,7 +399,7 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
206
  </div>
207
  <div>
208
  <label class="field-label" for="tags">
209
- Tags
210
  </label>
211
  <input
212
  id="tags"
@@ -234,6 +427,34 @@ export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
234
  ) : null}
235
  </main>
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  <Sheet
238
  open={Boolean(finishPrompt)}
239
  title="Finish now?"
 
1
+ /** Fast Log page: save an entry; high-FSE offers proof brick; pending offers Finish. */
2
 
3
  import { useEffect, useMemo, useState } from "preact/hooks";
4
+ import {
5
+ ApiError,
6
+ createEntry,
7
+ getDaily,
8
+ putDaily,
9
+ type DailyUpsert,
10
+ type ProofBrickKind,
11
+ } from "../api";
12
  import { Button } from "../components/Button";
13
  import { Pressable } from "../components/Pressable";
14
  import { ResultChips, type ResultValue } from "../components/ResultChips";
15
  import { Sheet } from "../components/Sheet";
16
  import { usePendingResolve } from "../components/PendingResolve";
17
  import { useToast } from "../components/Toast";
18
+ import { isoDate } from "../dates";
19
  import { navigate } from "../router";
20
 
21
  const EMOTION_PICKS = [
 
29
  "hope",
30
  ];
31
 
32
+ const TAG_SUGGESTS = [
33
+ "indoors",
34
+ "freeze",
35
+ "movement",
36
+ "interrupt_walk",
37
+ "fantasy_walk",
38
+ "headphones",
39
+ "proof",
40
+ ];
41
+
42
+ const FSE_EMOTIONS = new Set(["shame", "urge", "anxiety", "anger", "lonely"]);
43
+ const FSE_TAGS = new Set([
44
+ "freeze",
45
+ "indoors",
46
+ "rerun",
47
+ "court",
48
+ "daydream",
49
+ "fantasy",
50
+ "fantasy_walk",
51
+ ]);
52
+
53
+ const PROOF_KINDS: { value: ProofBrickKind; label: string }[] = [
54
+ { value: "leave_room", label: "Leave the room" },
55
+ { value: "admin_line", label: "One admin / trip line" },
56
+ { value: "send_message", label: "Send a real message" },
57
+ { value: "interrupt_walk", label: "Interrupt walk (no movie music)" },
58
+ { value: "body_care", label: "Body / room care" },
59
+ { value: "other", label: "Other proof" },
60
+ ];
61
+
62
+ function isHighFse(intensity: number, emotions: string[], tagList: string[]): boolean {
63
+ if (intensity < 7) return false;
64
+ const emos = emotions.map((e) => e.toLowerCase());
65
+ const tags = tagList.map((t) => t.toLowerCase());
66
+ return emos.some((e) => FSE_EMOTIONS.has(e)) || tags.some((t) => FSE_TAGS.has(t));
67
+ }
68
+
69
+ const EMPTY_DAILY_PATCH: Partial<DailyUpsert> = {
70
+ primary_brick: "none",
71
+ brick_done: false,
72
+ corn_sessions: 0,
73
+ delay_ok: true,
74
+ daydream: "none",
75
+ rerun: "clean",
76
+ court: "closed",
77
+ stayed_indoors_all_day: false,
78
+ left_room: false,
79
+ left_home: false,
80
+ movement_minutes: 0,
81
+ interrupt_walk_minutes: 0,
82
+ fantasy_walk_minutes: 0,
83
+ headphones_on_walk: false,
84
+ music_cinematic_on_walk: false,
85
+ proof_brick_done: false,
86
+ proof_brick_kind: null,
87
+ fantasy_minutes_scheduled: 0,
88
+ fantasy_minutes_unplanned: 0,
89
+ note: "",
90
+ };
91
+
92
  export function Log({ initialRemedy = "" }: { initialRemedy?: string }) {
93
  const toast = useToast();
94
  const { openResolveId } = usePendingResolve();
 
103
  const [moreOpen, setMoreOpen] = useState(false);
104
  const [saving, setSaving] = useState(false);
105
  const [finishPrompt, setFinishPrompt] = useState<{ id: string } | null>(null);
106
+ const [proofOpen, setProofOpen] = useState(false);
107
+ const [proofBusy, setProofBusy] = useState(false);
108
+ const [pendingAfterProof, setPendingAfterProof] = useState<{
109
+ id: string;
110
+ pending: boolean;
111
+ } | null>(null);
112
 
113
  useEffect(() => {
114
  setRemedy(initialRemedy);
 
119
  [happened, saving],
120
  );
121
 
122
+ const tagList = useMemo(
123
+ () =>
124
+ tags
125
+ .split(",")
126
+ .map((t) => t.trim())
127
+ .filter(Boolean),
128
+ [tags],
129
+ );
130
+
131
  const toggleEmotion = (emotion: string) => {
132
  setEmotions((current) =>
133
  current.includes(emotion)
 
138
  );
139
  };
140
 
141
+ const toggleTag = (tag: string) => {
142
+ const parts = tagList;
143
+ const lower = parts.map((t) => t.toLowerCase());
144
+ if (lower.includes(tag)) {
145
+ setTags(parts.filter((t) => t.toLowerCase() !== tag).join(", "));
146
+ } else {
147
+ setTags([...parts, tag].join(", "));
148
+ }
149
+ };
150
+
151
+ const afterSaveNavigate = (entryId: string, isPending: boolean) => {
152
+ if (isPending) {
153
+ setFinishPrompt({ id: entryId });
154
+ } else {
155
+ navigate("/");
156
+ }
157
+ };
158
+
159
  const onSave = async () => {
160
  if (!canSave) return;
161
  setSaving(true);
 
167
  intensity,
168
  remedy: remedy.trim(),
169
  result,
170
+ tags: tagList,
 
 
 
171
  notes: notes.trim(),
172
  });
173
  toast.show("Saved");
 
178
  ) {
179
  navigator.vibrate(10);
180
  }
181
+ const pending = entry.result === "pending";
182
+ if (isHighFse(intensity, emotions, tagList)) {
183
+ setPendingAfterProof({ id: entry.id, pending });
184
+ setProofOpen(true);
185
  } else {
186
+ afterSaveNavigate(entry.id, pending);
187
  }
188
  } catch (err) {
189
  const message =
 
194
  }
195
  };
196
 
197
+ const closeProof = () => {
198
+ setProofOpen(false);
199
+ const next = pendingAfterProof;
200
+ setPendingAfterProof(null);
201
+ if (next) afterSaveNavigate(next.id, next.pending);
202
+ else navigate("/");
203
+ };
204
+
205
+ const saveProof = async (kind: ProofBrickKind) => {
206
+ setProofBusy(true);
207
+ const today = isoDate(new Date());
208
+ try {
209
+ let base: DailyUpsert = { ...EMPTY_DAILY_PATCH } as DailyUpsert;
210
+ try {
211
+ const row = await getDaily(today);
212
+ base = {
213
+ primary_brick: row.primary_brick,
214
+ brick_done: row.brick_done,
215
+ corn_sessions: row.corn_sessions,
216
+ delay_ok: row.delay_ok,
217
+ daydream: row.daydream,
218
+ rerun: row.rerun,
219
+ court: row.court,
220
+ stayed_indoors_all_day: Boolean(row.stayed_indoors_all_day),
221
+ left_room: Boolean(row.left_room) || kind === "leave_room",
222
+ left_home: Boolean(row.left_home),
223
+ movement_minutes: row.movement_minutes ?? 0,
224
+ interrupt_walk_minutes: row.interrupt_walk_minutes ?? 0,
225
+ fantasy_walk_minutes: row.fantasy_walk_minutes ?? 0,
226
+ headphones_on_walk: Boolean(row.headphones_on_walk),
227
+ music_cinematic_on_walk: Boolean(row.music_cinematic_on_walk),
228
+ proof_brick_done: true,
229
+ proof_brick_kind: kind,
230
+ fantasy_minutes_scheduled: row.fantasy_minutes_scheduled ?? 0,
231
+ fantasy_minutes_unplanned: row.fantasy_minutes_unplanned ?? 0,
232
+ note: row.note || "",
233
+ };
234
+ } catch {
235
+ base = {
236
+ ...(EMPTY_DAILY_PATCH as DailyUpsert),
237
+ left_room: kind === "leave_room",
238
+ proof_brick_done: true,
239
+ proof_brick_kind: kind,
240
+ };
241
+ }
242
+ if (kind === "interrupt_walk" && !base.interrupt_walk_minutes) {
243
+ base.interrupt_walk_minutes = 10;
244
+ base.movement_minutes =
245
+ (base.interrupt_walk_minutes || 0) + (base.fantasy_walk_minutes || 0);
246
+ }
247
+ await putDaily(today, base);
248
+ toast.show("Proof noted on Daily");
249
+ closeProof();
250
+ } catch {
251
+ try {
252
+ await createEntry({
253
+ activity: "Proof brick",
254
+ happened: `Proof step: ${kind}`,
255
+ emotions: [],
256
+ intensity: Math.min(intensity, 5),
257
+ remedy: kind.replace(/_/g, " "),
258
+ result: "worked",
259
+ tags: ["proof", kind],
260
+ });
261
+ toast.show("Proof logged");
262
+ closeProof();
263
+ } catch (err) {
264
+ toast.show(
265
+ err instanceof ApiError ? err.message : "Could not save proof",
266
+ "error",
267
+ );
268
+ }
269
+ } finally {
270
+ setProofBusy(false);
271
+ }
272
+ };
273
+
274
  return (
275
  <div class="app-shell">
276
  <header class="top-bar material-bar">
 
349
  type="text"
350
  value={remedy}
351
  onInput={(e) => setRemedy((e.target as HTMLInputElement).value)}
352
+ placeholder="One concrete action — not more labeling at high heat"
353
  />
354
  </div>
355
 
356
+ <div>
357
+ <span class="field-label">Tags</span>
358
+ <div class="chip-row" role="group" aria-label="Suggested tags">
359
+ {TAG_SUGGESTS.map((tag) => (
360
+ <Pressable
361
+ key={tag}
362
+ className="chip"
363
+ ariaPressed={tagList.map((t) => t.toLowerCase()).includes(tag)}
364
+ onClick={() => toggleTag(tag)}
365
+ >
366
+ {tag}
367
+ </Pressable>
368
+ ))}
369
+ </div>
370
+ </div>
371
+
372
  <Button disabled={!canSave} onClick={onSave} className="btn-large">
373
  {saving ? "Saving…" : "Save entry"}
374
  </Button>
 
399
  </div>
400
  <div>
401
  <label class="field-label" for="tags">
402
+ More tags
403
  </label>
404
  <input
405
  id="tags"
 
427
  ) : null}
428
  </main>
429
 
430
+ <Sheet
431
+ open={proofOpen}
432
+ title="One proof step (not more labeling)"
433
+ onClose={closeProof}
434
+ >
435
+ <div class="stack">
436
+ <p class="muted">
437
+ High spike logged. Pick one small real step you can do now — body,
438
+ room, message, or interrupt walk.
439
+ </p>
440
+ <div class="choice-list">
441
+ {PROOF_KINDS.map((opt) => (
442
+ <Pressable
443
+ key={opt.value}
444
+ className="choice-item"
445
+ disabled={proofBusy}
446
+ onClick={() => void saveProof(opt.value)}
447
+ >
448
+ {opt.label}
449
+ </Pressable>
450
+ ))}
451
+ </div>
452
+ <Button className="btn-secondary" disabled={proofBusy} onClick={closeProof}>
453
+ Skip for now
454
+ </Button>
455
+ </div>
456
+ </Sheet>
457
+
458
  <Sheet
459
  open={Boolean(finishPrompt)}
460
  title="Finish now?"
frontend/src/styles/app.css CHANGED
@@ -873,6 +873,25 @@
873
  color: var(--text-secondary);
874
  }
875
  .tip-card p { margin: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
  .tip-dismiss {
877
  position: absolute;
878
  top: 6px;
 
873
  color: var(--text-secondary);
874
  }
875
  .tip-card p { margin: 0; }
876
+ .indoors-nudge {
877
+ flex-direction: column;
878
+ align-items: flex-start;
879
+ padding-right: 16px;
880
+ gap: 0.35rem;
881
+ background: #f3e8d8;
882
+ color: var(--text);
883
+ }
884
+ .warning-chip {
885
+ display: inline-block;
886
+ font-size: 11px;
887
+ font-weight: 700;
888
+ letter-spacing: 0.02em;
889
+ text-transform: uppercase;
890
+ padding: 0.2rem 0.5rem;
891
+ border-radius: 6px;
892
+ background: #e8a31733;
893
+ color: #8a5a00;
894
+ }
895
  .tip-dismiss {
896
  position: absolute;
897
  top: 6px;
scripts/pull_handoff_bundle.py ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Pull live Habit Journal state into a context-free handoff markdown bundle.
3
+
4
+ Reads env (or repo-root .env): HF_SPACE_URL, AGENT_TOKEN, APP_PASSWORD.
5
+ Writes handoff/HANDOFF_BUNDLE_{TODAY}.md and handoff/raw/*.json (gitignored).
6
+
7
+ Never prints APP_PASSWORD or bearer tokens into the bundle.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ from datetime import date, datetime, timedelta, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from urllib.error import HTTPError, URLError
20
+ from urllib.parse import urlencode
21
+ from urllib.request import Request, build_opener, HTTPCookieProcessor
22
+ from http.cookiejar import CookieJar
23
+
24
+ try:
25
+ from zoneinfo import ZoneInfo
26
+ except ImportError: # pragma: no cover
27
+ ZoneInfo = None # type: ignore[misc, assignment]
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[1]
30
+ HANDOFF_DIR = REPO_ROOT / "handoff"
31
+ RAW_DIR = HANDOFF_DIR / "raw"
32
+ TZ_NAME = "Africa/Dar_es_Salaam"
33
+ DEFAULT_SPACE = "https://Mbonea-Fastwhisper.hf.space"
34
+
35
+ PHONE_RE = re.compile(r"\b\d{8,}\b")
36
+ EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
37
+ SECRET_KEYS = frozenset(
38
+ {
39
+ "password",
40
+ "old_password",
41
+ "new_password",
42
+ "app_password",
43
+ "authorization",
44
+ "cookie",
45
+ "token",
46
+ "agent_token",
47
+ "api_key",
48
+ "openrouter_api_key",
49
+ "secret",
50
+ "secret_key",
51
+ }
52
+ )
53
+
54
+
55
+ def load_dotenv(path: Path) -> None:
56
+ if not path.is_file():
57
+ return
58
+ for line in path.read_text(encoding="utf-8").splitlines():
59
+ line = line.strip()
60
+ if not line or line.startswith("#") or "=" not in line:
61
+ continue
62
+ key, value = line.split("=", 1)
63
+ key = key.strip()
64
+ value = value.strip().strip("'").strip('"')
65
+ if key and key not in os.environ:
66
+ os.environ[key] = value
67
+
68
+
69
+ def today_in_tz() -> date:
70
+ """Africa/Dar_es_Salaam is UTC+3 year-round (no DST)."""
71
+
72
+ try:
73
+ if ZoneInfo is not None:
74
+ return datetime.now(ZoneInfo(TZ_NAME)).date()
75
+ except Exception: # noqa: BLE001 — Windows often lacks tzdata
76
+ pass
77
+ return (datetime.now(timezone.utc) + timedelta(hours=3)).date()
78
+
79
+
80
+ def redact_text(value: str) -> str:
81
+ value = EMAIL_RE.sub("[email]", value)
82
+ value = PHONE_RE.sub("[digits]", value)
83
+ return value
84
+
85
+
86
+ def redact_obj(value: Any) -> Any:
87
+ if isinstance(value, dict):
88
+ out: dict[str, Any] = {}
89
+ for k, v in value.items():
90
+ if k.lower() in SECRET_KEYS or any(s in k.lower() for s in ("password", "token", "secret", "cookie")):
91
+ out[k] = "[redacted]"
92
+ else:
93
+ out[k] = redact_obj(v)
94
+ return out
95
+ if isinstance(value, list):
96
+ return [redact_obj(v) for v in value]
97
+ if isinstance(value, str):
98
+ return redact_text(value)
99
+ return value
100
+
101
+
102
+ def trunc(text: Any, n: int = 200) -> str:
103
+ s = redact_text(str(text or "")).replace("\n", " ").strip()
104
+ return s if len(s) <= n else s[: n - 1] + "…"
105
+
106
+
107
+ def pretty(data: Any) -> str:
108
+ return json.dumps(redact_obj(data), indent=2, ensure_ascii=False, default=str)
109
+
110
+
111
+ class SpaceClient:
112
+ def __init__(self, base_url: str, agent_token: str | None, password: str | None) -> None:
113
+ self.base = base_url.rstrip("/")
114
+ self.agent_token = agent_token or None
115
+ self.password = password or None
116
+ self.jar = CookieJar()
117
+ self.opener = build_opener(HTTPCookieProcessor(self.jar))
118
+ self.session_ok = False
119
+
120
+ def _request(
121
+ self,
122
+ method: str,
123
+ path: str,
124
+ *,
125
+ query: dict[str, Any] | None = None,
126
+ body: dict[str, Any] | None = None,
127
+ use_agent: bool = False,
128
+ use_session: bool = False,
129
+ ) -> tuple[int, Any]:
130
+ url = f"{self.base}{path}"
131
+ if query:
132
+ url = f"{url}?{urlencode({k: v for k, v in query.items() if v is not None})}"
133
+ headers = {
134
+ "Accept": "application/json",
135
+ "User-Agent": "habit-journal-handoff-pull/1.0",
136
+ }
137
+ data = None
138
+ if body is not None:
139
+ data = json.dumps(body).encode("utf-8")
140
+ headers["Content-Type"] = "application/json"
141
+ if use_agent and self.agent_token:
142
+ headers["Authorization"] = f"Bearer {self.agent_token}"
143
+ req = Request(url, data=data, headers=headers, method=method)
144
+ try:
145
+ with self.opener.open(req, timeout=60) as resp:
146
+ raw = resp.read().decode("utf-8", errors="replace")
147
+ status = getattr(resp, "status", 200)
148
+ try:
149
+ return status, json.loads(raw) if raw else None
150
+ except json.JSONDecodeError:
151
+ return status, {"raw_text": trunc(raw, 500)}
152
+ except HTTPError as exc:
153
+ raw = exc.read().decode("utf-8", errors="replace")
154
+ try:
155
+ payload = json.loads(raw) if raw else {"error": {"message": str(exc)}}
156
+ except json.JSONDecodeError:
157
+ payload = {"error": {"message": trunc(raw, 400)}}
158
+ return exc.code, payload
159
+ except URLError as exc:
160
+ return 0, {"error": {"message": str(exc.reason)}}
161
+
162
+ def get(
163
+ self,
164
+ path: str,
165
+ *,
166
+ query: dict[str, Any] | None = None,
167
+ prefer_agent: bool = False,
168
+ ) -> tuple[int, Any]:
169
+ if prefer_agent and self.agent_token:
170
+ status, data = self._request("GET", path, query=query, use_agent=True)
171
+ if status in (200, 201):
172
+ return status, data
173
+ if status not in (401, 403, 404):
174
+ return status, data
175
+ if self.session_ok or self.ensure_session():
176
+ return self._request("GET", path, query=query, use_session=True)
177
+ return 401, {"error": {"message": "No AGENT_TOKEN and session login failed"}}
178
+
179
+ def ensure_session(self) -> bool:
180
+ if self.session_ok:
181
+ return True
182
+ if not self.password:
183
+ return False
184
+ status, data = self._request(
185
+ "POST",
186
+ "/api/auth/login",
187
+ body={"password": self.password},
188
+ )
189
+ ok = status == 200 and isinstance(data, dict) and data.get("ok") is True
190
+ self.session_ok = ok
191
+ return ok
192
+
193
+
194
+ def unwrap(data: Any) -> Any:
195
+ if isinstance(data, dict) and "data" in data:
196
+ return data.get("data")
197
+ return data
198
+
199
+
200
+ def write_raw(name: str, payload: Any) -> Path:
201
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
202
+ path = RAW_DIR / name
203
+ path.write_text(pretty(payload) + "\n", encoding="utf-8")
204
+ return path
205
+
206
+
207
+ def checklist_line(flag: bool, label: str) -> str:
208
+ mark = "x" if flag else " "
209
+ return f"- [{mark}] {label}"
210
+
211
+
212
+ def build_bundle(
213
+ *,
214
+ pulled_at: str,
215
+ space_url: str,
216
+ today: date,
217
+ yday: date,
218
+ health: Any,
219
+ ready: Any,
220
+ agent_ctx: Any,
221
+ agent_status: int,
222
+ pending: list[dict[str, Any]],
223
+ recent: list[dict[str, Any]],
224
+ daily: dict[str, Any] | None,
225
+ plan_today: dict[str, Any] | None,
226
+ plan_yday: dict[str, Any] | None,
227
+ priors: Any,
228
+ stats: Any,
229
+ settings: Any,
230
+ raw_names: list[str],
231
+ ) -> str:
232
+ ctx = agent_ctx if isinstance(agent_ctx, dict) else {}
233
+ daily_items = list((daily or {}).get("items") or [])
234
+ week_points = (daily or {}).get("week_points")
235
+ risk = ctx.get("risk_1h") or (plan_today or {}).get("risk_1h") or {}
236
+ triggers = ctx.get("triggers_yesterday") or (plan_today or {}).get("triggers_yesterday") or []
237
+ capacity = ctx.get("capacity_hint")
238
+ if capacity is None:
239
+ capacity = (plan_today or {}).get("capacity_hint")
240
+
241
+ now_utc = datetime.now(timezone.utc)
242
+ cutoff_24h = now_utc - timedelta(hours=24)
243
+ entries_24h = 0
244
+ pending_old = 0
245
+ for e in recent:
246
+ ts = e.get("ts")
247
+ try:
248
+ dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
249
+ except ValueError:
250
+ continue
251
+ if dt.tzinfo is None:
252
+ dt = dt.replace(tzinfo=timezone.utc)
253
+ if dt >= cutoff_24h:
254
+ entries_24h += 1
255
+ if e.get("result") == "pending" and dt < cutoff_24h:
256
+ pending_old += 1
257
+
258
+ blocks_today = list((plan_today or {}).get("blocks") or [])
259
+ with_fb = sum(1 for b in blocks_today if b.get("feedback"))
260
+ strong = 0
261
+ for b in blocks_today:
262
+ fb = b.get("feedback") or {}
263
+ if fb.get("strong") or (
264
+ fb.get("did") in ("done", "partial")
265
+ and fb.get("actual_min") is not None
266
+ and fb.get("quality") is not None
267
+ and fb.get("fun") is not None
268
+ ):
269
+ strong += 1
270
+ strong_rate = (strong / len(blocks_today)) if blocks_today else None
271
+ has_explore = any(
272
+ b.get("intent") in ("explore", "restore_fun") for b in blocks_today
273
+ )
274
+
275
+ high_pending = any(
276
+ (e.get("intensity") or 0) >= 7 and e.get("result") == "pending" for e in pending + recent
277
+ )
278
+ court_rerun_flag = False
279
+ for row in daily_items:
280
+ if str(row.get("date")) not in (today.isoformat(), yday.isoformat()):
281
+ continue
282
+ if row.get("court") == "court":
283
+ court_rerun_flag = True
284
+ rerun = str(row.get("rerun") or "none").lower()
285
+ if rerun not in ("none", "", "false", "0", "closed"):
286
+ court_rerun_flag = True
287
+
288
+ overload = isinstance(capacity, (int, float)) and capacity < 0.55 and len(blocks_today) >= 5
289
+ prior_kinds = []
290
+ if isinstance(priors, dict):
291
+ prior_kinds = list(priors.get("kinds") or [])
292
+ empty_priors = not prior_kinds or all(int(k.get("n") or 0) == 0 for k in prior_kinds if isinstance(k, dict))
293
+
294
+ lines: list[str] = []
295
+ a = lines.append
296
+ a("# Habit Journal pull — context-free bundle")
297
+ a(f"pulled_at_iso: {pulled_at}")
298
+ a(f"space_url: {space_url}")
299
+ a(f"timezone: {TZ_NAME}")
300
+ a(f"date_today: {today.isoformat()}")
301
+ a("")
302
+ a("## How to read this (for the external AI)")
303
+ a("You have NO prior chat unless the user adds it. Use only this bundle + what the user types.")
304
+ a(
305
+ "User goals often include: FSE/avoidance awareness, Spain GO (don’t cancel; don’t pay flights pre-visa), "
306
+ "Daily vs Log, schedule feedback, boundaries — but prefer DATA here over assumptions."
307
+ )
308
+ a("Do not ask for passwords.")
309
+ a("")
310
+ a("## Health")
311
+ a("### /api/health")
312
+ a("```json")
313
+ a(pretty(health))
314
+ a("```")
315
+ a("### /api/ready")
316
+ a("```json")
317
+ a(pretty(ready))
318
+ a("```")
319
+ if settings is not None:
320
+ a("### /api/settings/status")
321
+ a("```json")
322
+ a(pretty(settings))
323
+ a("```")
324
+ a("")
325
+ a("## Agent context (today)")
326
+ a(f"http_status: {agent_status}")
327
+ if ctx:
328
+ a("```json")
329
+ a(pretty(ctx))
330
+ a("```")
331
+ else:
332
+ a("_Agent context unavailable — used human-route fallbacks._")
333
+ a("")
334
+ a("## Pending entries")
335
+ a(f"count: {len(pending)}")
336
+ if not pending:
337
+ a("_none_")
338
+ else:
339
+ for e in pending:
340
+ a(
341
+ f"- `{e.get('id')}` · {e.get('ts')} · intensity={e.get('intensity')} · "
342
+ f"emotions={e.get('emotions')} · remedy={e.get('remedy')} · result={e.get('result')}"
343
+ )
344
+ a(f" happened: {trunc(e.get('happened'), 200)}")
345
+ a("")
346
+ a("## Recent entries (newest first, max 50)")
347
+ if not recent:
348
+ a("_none_")
349
+ else:
350
+ a("| ts | tags | emotions | intensity | result | remedy | happened |")
351
+ a("|---|---|---|---|---|---|---|")
352
+ for e in recent[:50]:
353
+ a(
354
+ f"| {e.get('ts')} | {','.join(e.get('tags') or [])} | "
355
+ f"{','.join(e.get('emotions') or [])} | {e.get('intensity')} | "
356
+ f"{e.get('result')} | {trunc(e.get('remedy'), 40)} | {trunc(e.get('happened'), 200)} |"
357
+ )
358
+ a("")
359
+ a("## Daily rows (last 7 days)")
360
+ if week_points is not None:
361
+ a(f"week_points: {week_points} · band: {(daily or {}).get('band')}")
362
+ if not daily_items:
363
+ a("_none_")
364
+ else:
365
+ a(
366
+ "| date | primary_brick | brick_done | corn_sessions | delay_ok | "
367
+ "daydream | rerun | court | points | note |"
368
+ )
369
+ a("|---|---|---|---|---|---|---|---|---|---|")
370
+ for row in daily_items:
371
+ a(
372
+ f"| {row.get('date')} | {row.get('primary_brick')} | {row.get('brick_done')} | "
373
+ f"{row.get('corn_sessions')} | {row.get('delay_ok')} | {row.get('daydream')} | "
374
+ f"{row.get('rerun')} | {row.get('court')} | {row.get('points')} | "
375
+ f"{trunc(row.get('note'), 80)} |"
376
+ )
377
+ a("")
378
+ a("## Plan today")
379
+ _append_plan(a, plan_today)
380
+ a("## Plan yesterday")
381
+ _append_plan(a, plan_yday)
382
+ a("")
383
+ a("## Feedback summary (if in context or derivable)")
384
+ a(f"blocks_today: {len(blocks_today)}")
385
+ a(f"blocks_with_feedback: {with_fb}")
386
+ a(f"strong_feedback_count: {strong}")
387
+ a(f"strong_feedback_rate: {None if strong_rate is None else round(strong_rate, 3)}")
388
+ a(f"has_explore_or_restore_block: {has_explore}")
389
+ for b in blocks_today:
390
+ fb = b.get("feedback")
391
+ if not fb:
392
+ continue
393
+ a(
394
+ f"- {b.get('start')}-{b.get('end')} {b.get('title')}: did={fb.get('did')} "
395
+ f"min={fb.get('actual_min')} q={fb.get('quality')} fun={fb.get('fun')} "
396
+ f"energy={fb.get('energy_after')} repeat={fb.get('would_repeat')}"
397
+ )
398
+ a("")
399
+ a("## Priors (schedule)")
400
+ if prior_kinds:
401
+ a("| kind | n | d_hat | p_done | mean_fun | repeat_score |")
402
+ a("|---|---|---|---|---|---|")
403
+ for k in prior_kinds:
404
+ if not isinstance(k, dict):
405
+ continue
406
+ a(
407
+ f"| {k.get('kind')} | {k.get('n')} | {k.get('d_hat')} | {k.get('p_done')} | "
408
+ f"{k.get('mean_fun')} | {k.get('repeat_score')} |"
409
+ )
410
+ md = (priors or {}).get("markdown") if isinstance(priors, dict) else None
411
+ if md:
412
+ a("")
413
+ a("```")
414
+ a(str(md))
415
+ a("```")
416
+ else:
417
+ a("_none / unavailable_")
418
+ a("")
419
+ a("## Stats snapshot (if any)")
420
+ if stats is None:
421
+ a("_unavailable_")
422
+ else:
423
+ a("```json")
424
+ a(pretty(stats))
425
+ a("```")
426
+ a("")
427
+ a("## Risk / triggers (from context)")
428
+ a(f"risk_1h: {pretty(risk)}")
429
+ a(f"triggers_yesterday: {pretty(triggers)}")
430
+ a(f"capacity_hint: {capacity}")
431
+ ops = ctx.get("operators") or (plan_today or {}).get("operators")
432
+ if ops:
433
+ a(f"operators: {pretty(ops)}")
434
+ a("")
435
+ a("## Derived (compute in script if easy)")
436
+ a(f"- pending_count: {len(pending)}")
437
+ a(f"- week_points_sum: {week_points}")
438
+ a(f"- entries_last_24h_count: {entries_24h}")
439
+ a(f"- pending_older_than_24h: {pending_old}")
440
+ a(f"- blocks_planned_today: {len(blocks_today)}")
441
+ a(f"- blocks_with_feedback: {with_fb}")
442
+ a(f"- strong_feedback_rate_today: {None if strong_rate is None else round(strong_rate, 3)}")
443
+ a("")
444
+ a("## Red flags (heuristic checklist for external AI)")
445
+ a(checklist_line(high_pending, "high intensity + pending unresolved"))
446
+ a(checklist_line(court_rerun_flag, "court/rerun open on daily"))
447
+ a(checklist_line(bool(overload), "plan overload vs capacity_hint"))
448
+ a(checklist_line(bool(blocks_today) and not has_explore, "no explore/restore_fun block"))
449
+ a(checklist_line(empty_priors, "DATA_THIN / empty priors"))
450
+ a("")
451
+ a("## Raw pointers")
452
+ a("local files: handoff/raw/...")
453
+ for name in raw_names:
454
+ a(f"- handoff/raw/{name}")
455
+ a("")
456
+ return "\n".join(lines)
457
+
458
+
459
+ def _append_plan(a, plan: dict[str, Any] | None) -> None:
460
+ if not plan:
461
+ a("_none / unavailable_")
462
+ a("")
463
+ return
464
+ a(f"version: {plan.get('version')} · source: {plan.get('source')} · capacity_hint: {plan.get('capacity_hint')}")
465
+ blocks = plan.get("blocks") or []
466
+ if not blocks:
467
+ a("_no blocks_")
468
+ else:
469
+ for b in blocks:
470
+ a(
471
+ f"- {b.get('start')}-{b.get('end')} · {b.get('title')} · kind={b.get('kind')} · "
472
+ f"priority={b.get('priority')} · status={b.get('status')} · locked={b.get('locked')}"
473
+ )
474
+ a("")
475
+
476
+
477
+ def main() -> int:
478
+ load_dotenv(REPO_ROOT / ".env")
479
+ space = (os.environ.get("HF_SPACE_URL") or DEFAULT_SPACE).rstrip("/")
480
+ agent_token = os.environ.get("AGENT_TOKEN") or ""
481
+ password = os.environ.get("APP_PASSWORD") or ""
482
+
483
+ today = today_in_tz()
484
+ yday = today - timedelta(days=1)
485
+ week_start = today - timedelta(days=6)
486
+ pulled_at = datetime.now(timezone.utc).isoformat()
487
+
488
+ client = SpaceClient(space, agent_token or None, password or None)
489
+ HANDOFF_DIR.mkdir(parents=True, exist_ok=True)
490
+ RAW_DIR.mkdir(parents=True, exist_ok=True)
491
+
492
+ raw_names: list[str] = []
493
+
494
+ def save(name: str, status: int, payload: Any) -> Any:
495
+ envelope = {"http_status": status, "body": payload}
496
+ write_raw(name, envelope)
497
+ raw_names.append(name)
498
+ return unwrap(payload) if status == 200 else None
499
+
500
+ st_h, health_body = client.get("/api/health")
501
+ health = save(f"health_{today.isoformat()}.json", st_h, health_body)
502
+
503
+ st_r, ready_body = client.get("/api/ready")
504
+ ready = save(f"ready_{today.isoformat()}.json", st_r, ready_body)
505
+
506
+ # Agent API uses ?day= (not date=)
507
+ st_a, agent_body = (0, None)
508
+ if agent_token:
509
+ st_a, agent_body = client._request(
510
+ "GET",
511
+ "/api/agent/context",
512
+ query={"day": today.isoformat()},
513
+ use_agent=True,
514
+ )
515
+ if st_a not in (200, 201):
516
+ # session fallback to same agent route (supports session) or skip
517
+ st_a2, agent_body2 = client.get(
518
+ "/api/agent/context",
519
+ query={"day": today.isoformat()},
520
+ prefer_agent=False,
521
+ )
522
+ if st_a2 in (200, 201):
523
+ st_a, agent_body = st_a2, agent_body2
524
+ agent_ctx = save(f"agent_context_{today.isoformat()}.json", st_a, agent_body)
525
+
526
+ # Human extras (session)
527
+ if not client.session_ok:
528
+ client.ensure_session()
529
+
530
+ st_p, pending_body = client.get("/api/entries", query={"result": "pending", "limit": 50})
531
+ pending_data = save(f"entries_pending_{today.isoformat()}.json", st_p, pending_body)
532
+ pending = list((pending_data or {}).get("items") or []) if isinstance(pending_data, dict) else []
533
+
534
+ st_e, recent_body = client.get(
535
+ "/api/entries",
536
+ query={"start": week_start.isoformat(), "end": today.isoformat(), "limit": 50},
537
+ )
538
+ recent_data = save(f"entries_recent_{today.isoformat()}.json", st_e, recent_body)
539
+ recent = list((recent_data or {}).get("items") or []) if isinstance(recent_data, dict) else []
540
+ # API may return oldest-first; prefer newest first
541
+ recent = sorted(recent, key=lambda e: str(e.get("ts") or ""), reverse=True)
542
+
543
+ st_d, daily_body = client.get(
544
+ "/api/daily",
545
+ query={"start": week_start.isoformat(), "end": today.isoformat()},
546
+ )
547
+ daily = save(f"daily_{week_start.isoformat()}_{today.isoformat()}.json", st_d, daily_body)
548
+
549
+ st_pt, plan_t_body = client.get(f"/api/plan/{today.isoformat()}")
550
+ plan_today = save(f"plan_{today.isoformat()}.json", st_pt, plan_t_body)
551
+
552
+ st_py, plan_y_body = client.get(f"/api/plan/{yday.isoformat()}")
553
+ plan_yday = save(f"plan_{yday.isoformat()}.json", st_py, plan_y_body)
554
+
555
+ st_pr, priors_body = client.get("/api/schedule/priors")
556
+ priors = save(f"priors_{today.isoformat()}.json", st_pr, priors_body)
557
+
558
+ st_s, stats_body = client.get(
559
+ "/api/stats",
560
+ query={"start": week_start.isoformat(), "end": today.isoformat()},
561
+ )
562
+ stats = save(f"stats_{today.isoformat()}.json", st_s, stats_body)
563
+
564
+ st_set, settings_body = client.get("/api/settings/status")
565
+ settings = save(f"settings_status_{today.isoformat()}.json", st_set, settings_body)
566
+
567
+ # Prefer agent-context plan/priors when human routes empty
568
+ if isinstance(agent_ctx, dict):
569
+ if not plan_today and isinstance(agent_ctx.get("current_plan"), dict):
570
+ plan_today = agent_ctx["current_plan"]
571
+ if not priors and agent_ctx.get("priors") is not None:
572
+ priors = {
573
+ "kinds": agent_ctx.get("priors"),
574
+ "markdown": agent_ctx.get("server_priors_markdown"),
575
+ }
576
+ if not pending and agent_ctx.get("pending_entries_count"):
577
+ # keep pending list from entries; count still in derived
578
+ pass
579
+
580
+ md = build_bundle(
581
+ pulled_at=pulled_at,
582
+ space_url=space,
583
+ today=today,
584
+ yday=yday,
585
+ health=health if health is not None else health_body,
586
+ ready=ready if ready is not None else ready_body,
587
+ agent_ctx=agent_ctx if isinstance(agent_ctx, dict) else None,
588
+ agent_status=st_a,
589
+ pending=pending,
590
+ recent=recent,
591
+ daily=daily if isinstance(daily, dict) else None,
592
+ plan_today=plan_today if isinstance(plan_today, dict) else None,
593
+ plan_yday=plan_yday if isinstance(plan_yday, dict) else None,
594
+ priors=priors,
595
+ stats=stats,
596
+ settings=settings,
597
+ raw_names=sorted(set(raw_names)),
598
+ )
599
+
600
+ out_path = HANDOFF_DIR / f"HANDOFF_BUNDLE_{today.isoformat()}.md"
601
+ out_path.write_text(md, encoding="utf-8")
602
+ print(f"Wrote {out_path.relative_to(REPO_ROOT)}")
603
+ print(f"Raw JSON under {RAW_DIR.relative_to(REPO_ROOT)}/")
604
+ if not agent_token and not password:
605
+ print("WARN: set AGENT_TOKEN and/or APP_PASSWORD in .env for authenticated pulls", file=sys.stderr)
606
+ return 2
607
+ if st_a not in (200, 201) and st_e not in (200, 201):
608
+ print(f"WARN: limited data (agent={st_a}, entries={st_e})", file=sys.stderr)
609
+ return 1
610
+ return 0
611
+
612
+
613
+ if __name__ == "__main__":
614
+ raise SystemExit(main())
static/assets/{index-6RturTFo.css → index-CK7MU-TQ.css} RENAMED
@@ -1 +1 @@
1
- :root{color-scheme:light;--bg: #ebe7e0;--bg-elevated: #f1eee8;--card: #f8f6f2;--surface: #f8f6f2;--surface-solid: #f8f6f2;--surface-secondary: #f1eee8;--text: #202020;--text-secondary: #666666;--text-tertiary: #9a9a9a;--separator: rgba(32, 32, 32, .08);--accent: #e8a317;--accent-soft: rgba(232, 163, 23, .16);--ok: #2f6b3c;--ok-bg: #e4f0e6;--warn: #8a6a10;--warn-bg: #f5ebcc;--danger: #8b3a34;--danger-bg: #f0e0de;--pending-bg: #faf3e3;--pending-border: #e8a317;--scrim: rgba(32, 32, 32, .35);--shadow: 0 8px 24px rgba(32, 32, 32, .06);--radius: 20px;--pad: 1.25rem;--hit: 44px;--btn-h: 56px;--fab-size: 60px;--tab-h: 76px;--font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;--safe-top: env(safe-area-inset-top, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);font:400 16px/1.47 var(--font);background:var(--bg);color:var(--text);letter-spacing:0}@media(prefers-contrast:more){:root{--separator: rgba(32, 32, 32, .28);--text-secondary: #444444;--shadow: none}}*,*:before,*:after{box-sizing:border-box}html,body,#app{min-height:100%;margin:0}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;border:none;background:none}a{color:var(--accent);text-decoration:none}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}img{max-width:100%}.material-bar{background:#f8f6f2eb;border-bottom:1px solid var(--separator)}.sheet-material{background:var(--card);border-radius:20px 20px 0 0;box-shadow:0 -8px 40px #2020201f}.surface-card{background:var(--card);border:1px solid var(--separator);border-radius:var(--radius);box-shadow:var(--shadow)}@media(prefers-reduced-transparency:reduce){.material-bar,.sheet-material,.surface-card{backdrop-filter:none;-webkit-backdrop-filter:none;background:var(--surface-solid)}}@media(prefers-contrast:more){.material-bar,.sheet-material,.surface-card{border-color:#20202047;box-shadow:none}}.app-shell{min-height:100dvh;display:flex;flex-direction:column;background:var(--bg)}.top-bar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:.75rem;min-height:calc(var(--hit) + var(--safe-top));padding:calc(.5rem + var(--safe-top)) var(--pad) .75rem}.top-bar h1,.page-title{margin:0;font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.1}.page{flex:1;width:min(40rem,100%);margin:0 auto;padding:1.25rem var(--pad) calc(1.5rem + var(--safe-bottom) + var(--tab-h) + .75rem)}.home-page{gap:16px;animation:card-in .18s ease both}.stack{display:flex;flex-direction:column;gap:16px}.field-label{display:block;margin-bottom:.4rem;color:var(--text-secondary);font-size:13px;font-weight:500}.field-input,.field-textarea{width:100%;min-height:var(--hit);padding:.85rem 1rem;border-radius:16px;border:1px solid var(--separator);background:var(--card);color:var(--text)}.field-textarea{min-height:7rem;resize:vertical;line-height:1.47}.muted{color:var(--text-secondary)}.tertiary{color:var(--text-tertiary)}.error-text{color:var(--danger);font-size:.9rem}.chip-row{display:flex;flex-wrap:wrap;gap:.5rem}.chip{min-height:var(--hit);padding:0 .95rem;border-radius:999px;border:1px solid var(--separator);background:var(--card);color:var(--text-secondary);font-weight:500;display:inline-flex;align-items:center;gap:.35rem}.chip[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--text)}.chip.result-worked[aria-pressed=true]{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}.chip.result-partial[aria-pressed=true]{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}.chip.result-failed[aria-pressed=true]{border-color:var(--danger);background:var(--danger-bg);color:var(--danger)}.chip.result-pending[aria-pressed=true]{border-color:var(--pending-border);background:var(--pending-bg);color:var(--accent)}.slider-wrap{display:grid;gap:.5rem}.slider-wrap input[type=range]{width:100%;accent-color:var(--accent)}.more-block{border-top:1px solid var(--separator);padding-top:1rem}.login-page{min-height:100dvh;display:grid;place-items:center;padding:var(--pad);padding-top:calc(var(--pad) + var(--safe-top));padding-bottom:calc(var(--pad) + var(--safe-bottom));background:var(--bg)}.login-card{width:min(22rem,100%);padding:1.5rem}.login-card h1{margin:0 0 .35rem;font-size:32px;font-weight:700;letter-spacing:-.02em;line-height:1.1}.toast-host{position:fixed;left:0;right:0;top:calc(.75rem + var(--safe-top));z-index:50;display:flex;justify-content:center;pointer-events:none;padding:0 var(--pad)}.toast{pointer-events:auto;max-width:24rem;width:100%;padding:.85rem 1rem;border-radius:16px;background:var(--card);border:1px solid var(--separator);box-shadow:var(--shadow);color:var(--text);font-weight:500}.toast.error{border-color:var(--danger);background:var(--danger-bg)}.pressable{touch-action:manipulation;-webkit-tap-highlight-color:transparent;transform-origin:center}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:var(--btn-h);padding:0 1.25rem;border-radius:999px;font-weight:600}.btn:disabled{opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);color:#202020}.btn-secondary{background:var(--card);border:1px solid var(--separator);color:var(--text)}.btn-destructive{background:var(--danger-bg);color:var(--danger);border:1px solid rgba(139,58,52,.25)}.btn-plain{color:var(--text-secondary);min-height:var(--hit);padding:0 .35rem;justify-content:flex-start;font-weight:500}.btn-large{min-height:var(--btn-h);width:100%;font-size:1.0625rem}.tab-bar{position:fixed;left:0;right:0;bottom:0;z-index:30;min-height:calc(var(--tab-h) + var(--safe-bottom));padding-bottom:var(--safe-bottom);background:var(--card);border-top:1px solid var(--separator)}.tab-bar-inner{display:grid;grid-template-columns:1fr 1fr 60px 1fr 1fr;align-items:stretch;min-height:var(--tab-h);padding:0 .35rem}.tab-item{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.2rem;min-height:var(--tab-h);color:var(--text-tertiary);font-size:11px;font-weight:500;letter-spacing:.01em}.tab-item.active{color:var(--text);font-weight:600}.tab-item.active svg{color:var(--accent)}.tab-fab-slot{width:var(--fab-size)}.tab-fab{position:absolute;left:50%;top:-10px;transform:translate(-50%);width:var(--fab-size);height:var(--fab-size);border-radius:999px;background:var(--accent);box-shadow:0 10px 24px #e8a31759;display:grid;place-items:center}.home-greeting{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.greeting-title{margin:0;font-size:32px;font-weight:700;letter-spacing:-.02em;line-height:1.1}.greeting-sub{margin:.35rem 0 0;color:var(--text-secondary);font-size:15px}.avatar-circle{width:48px;height:48px;border-radius:999px;background:var(--accent-soft);color:var(--text);display:grid;place-items:center;font-weight:700;font-size:14px;flex-shrink:0}.week-calendar{display:grid;grid-template-columns:repeat(7,1fr);gap:.35rem}.week-cal-cell{display:grid;justify-items:center;gap:.25rem;padding:.55rem .2rem;border-radius:16px;background:transparent}.week-cal-cell.today{background:var(--accent);color:#202020}.week-cal-label{font-size:11px;font-weight:600;color:inherit;opacity:.7}.week-cal-num{font-size:15px;font-weight:700}.week-cal-dot{width:6px;height:6px;border-radius:999px;background:transparent}.week-cal-cell.filled .week-cal-dot{background:var(--accent)}.week-cal-cell.today.filled .week-cal-dot,.week-cal-cell.today .week-cal-dot{background:#202020}.pending-banner-card{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:.75rem;min-height:100px;padding:.75rem 1rem;border-radius:var(--radius);background:var(--pending-bg);border:1.5px solid var(--pending-border);text-align:left;box-shadow:var(--shadow)}.pending-illu{width:88px;height:auto}.pending-copy{display:grid;gap:.15rem}.pending-copy strong{font-size:18px;font-weight:600}.pending-copy span{color:var(--text-secondary);font-size:14px}.hero-score-card{display:grid;grid-template-columns:1fr 38%;align-items:center;gap:.5rem;padding:18px;overflow:hidden}.hero-score-label{color:var(--text-secondary);font-size:14px;font-weight:500}.hero-score-value{margin:.15rem 0;font-size:54px;font-weight:700;letter-spacing:-.03em;line-height:1}.hero-score-sub{margin:0;color:var(--text-secondary);font-size:14px}.hero-illu{width:100%;height:auto;justify-self:end}.week-progress-card{display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:center;padding:16px 18px}.week-progress-row{display:flex;justify-content:space-between;gap:.75rem;margin-bottom:.45rem;color:var(--text-secondary);font-size:14px}.week-progress-row strong{color:var(--text);font-size:18px}.progress-track{height:8px;border-radius:999px;background:var(--surface-secondary);overflow:hidden;margin-top:.35rem}.progress-fill{height:100%;border-radius:inherit;background:var(--accent)}.scores-illu{width:72px;height:auto}.section-title{margin:0;font-size:20px;font-weight:600}.entry-list{display:grid;gap:.65rem}.entry-row{display:grid;grid-template-columns:auto 1fr;gap:.75rem;align-items:start;padding:16px;min-height:var(--hit);text-align:left;border-radius:var(--radius);background:var(--card);border:1px solid var(--separator);box-shadow:var(--shadow)}.entry-row.pending-row{background:var(--pending-bg);border-color:var(--pending-border)}.entry-row-main{display:grid;gap:.25rem;min-width:0}.entry-preview{color:var(--text);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.entry-row time{color:var(--text-tertiary);font-size:13px}.result-chip{display:inline-flex;align-items:center;gap:.25rem;padding:.3rem .55rem;border-radius:999px;font-size:12px;font-weight:600;text-transform:capitalize;white-space:nowrap}.result-chip.result-worked{background:var(--ok-bg);color:var(--ok)}.result-chip.result-partial{background:var(--warn-bg);color:var(--warn)}.result-chip.result-failed{background:var(--danger-bg);color:var(--danger)}.result-chip.result-pending{background:var(--pending-bg);color:var(--accent);border:1px solid var(--pending-border)}.quick-actions{display:flex;flex-wrap:wrap;gap:.5rem}.quick-chip{min-height:40px;padding:0 1rem;border-radius:999px;background:var(--surface-secondary);color:var(--text);font-weight:500;font-size:14px}.search-field{position:relative;display:block}.search-field svg{position:absolute;left:.9rem;top:50%;transform:translateY(-50%);color:var(--text-tertiary);pointer-events:none}.search-input{padding-left:2.4rem}.empty-state{padding:2rem 1.25rem;text-align:center;display:grid;gap:.75rem;justify-items:center}.detail-card,.form-card,.stats-card,.settings-card,.coach-result,.score-card{padding:16px 18px}.detail-snippet{margin:0;font-size:18px;font-weight:600;line-height:1.35}.meta-grid{display:grid;gap:.75rem}.section-head{display:flex;align-items:center;justify-content:space-between}.linkish{display:inline-flex;align-items:center;gap:.15rem;color:var(--accent);font-weight:600;font-size:14px;background:none;border:none;padding:0}.pending-focus{border:1.5px solid var(--pending-border);background:var(--pending-bg)}.resolve-snippet{padding:1rem}.resolve-activity{margin:0 0 .35rem;font-size:13px}.resolve-sticky{position:sticky;bottom:0;padding-top:.5rem;padding-bottom:.25rem;background:linear-gradient(transparent,var(--card) 28%)}.sheet-tall{max-height:85dvh!important}.gate-loading{min-height:100dvh;display:grid;place-items:center;color:var(--text-secondary);background:var(--bg)}.top-action{margin-left:auto;min-width:var(--hit);min-height:var(--hit);color:var(--accent);font-weight:600}.sheet-layer{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100}.sheet-scrim{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;background:#202020;opacity:0}.sheet-panel{position:absolute;left:0;right:0;bottom:0;max-height:min(82dvh,44rem);overflow:auto;padding-bottom:var(--safe-bottom);will-change:transform}.sheet-handle-zone{position:sticky;top:0;z-index:1;display:grid;justify-items:center;gap:.45rem;padding:.5rem var(--pad) .75rem;background:inherit;touch-action:none;-webkit-user-select:none;user-select:none}.sheet-handle{width:2.25rem;height:.3rem;border-radius:99px;background:var(--text-tertiary)}.sheet-handle-zone h2{margin:0;font-size:20px;font-weight:600}.sheet-content{padding:0 var(--pad) 1.25rem}.menu-list{display:grid}.menu-row{min-height:3.25rem;width:100%;text-align:left;border-bottom:1px solid var(--separator);color:var(--text)}.destructive-text{color:var(--danger)}.result-label{font-size:.75rem;font-weight:600;text-transform:capitalize}.result-worked{color:var(--ok)}.result-partial{color:var(--warn)}.result-failed{color:var(--danger)}.result-pending{color:var(--accent)}.detail-card p,.coach-text{margin:0;white-space:pre-wrap}.result-heading,.status-row,.score-card,.toggle-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem}.source-badge{padding:.2rem .55rem;border-radius:99px;font-size:.75rem;font-weight:700}.source-badge.rules{background:var(--warn-bg);color:var(--warn)}.source-badge.model{background:var(--accent-soft);color:#8a6408}.flags{color:var(--text-tertiary);font-size:.68rem;text-align:right}.pick-list{display:grid;overflow:hidden;border-radius:var(--radius);background:var(--surface-secondary)}.pick-row{display:flex;justify-content:space-between;gap:.75rem;min-height:3rem;padding:.65rem .85rem;text-align:left;border-bottom:1px solid var(--separator)}.pick-row span:last-child{white-space:nowrap;font-size:.75rem}.disclaimer{font-size:.8rem;text-align:center}.coach-input{min-height:9rem}.segment-row{display:flex;flex-wrap:wrap;gap:.4rem}.segment{min-width:var(--hit);min-height:var(--hit);padding:0 .75rem;border:1px solid var(--separator);border-radius:999px;color:var(--text-secondary);background:var(--card)}.segment[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--text);font-weight:600}.toggle-row{min-height:var(--hit)}.toggle-row input{width:1.4rem;height:1.4rem;accent-color:var(--accent)}.score-card>div{display:grid}.score-card strong,.score-readout{font-size:1.5rem;font-weight:700}.stats-card{display:grid;gap:.85rem}.outcome-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem}.outcome-grid div{display:grid;text-align:center;gap:.2rem}.outcome-grid strong,.large-number{font-size:1.5rem}.stat-row{display:grid;gap:.35rem}.stat-row>div:first-child{display:flex;justify-content:space-between;gap:.5rem}.bar-track{height:.35rem;border-radius:99px;background:var(--surface-secondary);overflow:hidden}.bar-track span{display:block;height:100%;border-radius:inherit;background:var(--accent)}.install-steps{margin:0;padding-left:1.4rem;display:grid;gap:.5rem}.daily-header{justify-content:space-between;align-items:center}.daily-date{font-size:13px;color:var(--text-secondary)}.daily-points{font-size:1.35rem;font-weight:700;color:var(--warn)}.tip-card{position:relative;display:flex;align-items:flex-start;gap:.5rem;padding:12px 44px 12px 16px;border-radius:var(--radius);background:var(--accent-soft);font-size:14px;color:var(--text-secondary)}.tip-card p{margin:0}.tip-dismiss{position:absolute;top:6px;right:6px;display:grid;place-items:center;width:32px;height:32px;border-radius:999px;color:var(--text-secondary)}.choice-list{display:grid;gap:.5rem}.choice-item{min-height:var(--hit);padding:.7rem 1rem;text-align:left;border:1px solid var(--separator);border-radius:14px;background:var(--card);color:var(--text-secondary);font-size:15px}.choice-item[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--text);font-weight:600}.stepper-row{display:flex;align-items:center;gap:1rem}.stepper-btn{width:48px;height:48px;border-radius:999px;border:1px solid var(--separator);background:var(--card);font-size:1.4rem;line-height:1}.stepper-btn[disabled]{opacity:.4}.stepper-value{min-width:2.5rem;text-align:center;font-size:1.6rem;font-weight:700}.points-breakdown{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.points-breakdown li{display:flex;justify-content:space-between;gap:.75rem;font-size:14px}.points-breakdown li.missed{color:var(--text-tertiary)}.points-breakdown .points-pts{font-weight:700}.points-breakdown li.earned .points-pts{color:var(--ok)}.how-points{padding:14px 18px;font-size:14px;color:var(--text-secondary)}.how-points summary{cursor:pointer;font-weight:600;color:var(--text);min-height:var(--hit);display:flex;align-items:center}.how-points ul{margin:.5rem 0 0;padding-left:1.2rem;display:grid;gap:.4rem}.daily-sticky{position:sticky;bottom:0;padding:.5rem 0 .25rem;background:linear-gradient(to top,var(--bg) 70%,transparent)}.plan-header{flex-wrap:wrap;gap:.5rem}.plan-day-nav{display:flex;align-items:center;gap:.35rem}.icon-btn{width:44px;height:44px;display:grid;place-items:center;border-radius:999px;color:var(--text)}.chip-btn{min-height:36px;padding:0 .85rem;border-radius:999px;background:var(--accent-soft);color:var(--text);font-weight:600;font-size:13px}.plan-meta-row{display:flex;justify-content:space-between;gap:.75rem}.caption{font-size:13px;color:var(--text-secondary)}.plan-actions{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}.timeline-card{padding:12px 8px 12px 0;overflow:hidden}.timeline{position:relative;display:grid;grid-template-columns:56px 1fr}.timeline-gutter{position:relative}.timeline-hour-label{position:absolute;right:8px;transform:translateY(-50%);font-size:11px;color:var(--text-tertiary)}.timeline-canvas{position:relative;border-left:1px solid var(--separator)}.timeline-hour-line{position:absolute;left:0;right:0;height:1px;background:var(--separator)}.timeline-now{position:absolute;left:0;right:0;height:2px;background:var(--accent);z-index:3}.timeline-now-dot{position:absolute;left:-5px;top:-4px;width:10px;height:10px;border-radius:999px;background:var(--accent)}.timeline-block{position:absolute;left:8px;right:8px;z-index:2;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:.1rem;padding:6px 10px;border-radius:12px;border:1px solid transparent;text-align:left;overflow:hidden}.timeline-block strong{font-size:13px;font-weight:600}.timeline-block span{font-size:11px;opacity:.85}.timeline-block.duty{background:#e8eef3;color:#243447}.timeline-block.explore{background:#e5f3ea;color:#1f4d32}.timeline-block.restore{background:#f3e8f0;color:#4a2f45}.timeline-block.stabilize{background:#f7ecd8;color:#5a4418}.timeline-block.flex{background:var(--surface-secondary);color:var(--text)}.timeline-block.p0{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.timeline-block.done{opacity:.55}.timeline-block.skipped{opacity:.4;text-decoration:line-through}.plan-time-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.btn-secondary{min-height:var(--btn-h);width:100%;border-radius:999px;background:var(--card);border:1px solid var(--separator);color:var(--text);font-weight:600}.meta-line{margin:0;color:var(--text-secondary);font-size:14px}.empty-card{text-align:center}@keyframes card-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.home-page,.surface-card,.entry-row,.pending-banner-card{animation:none!important;transition:opacity .2s ease}.toast{transition:opacity .2s ease}}@media(prefers-reduced-transparency:reduce){.sheet-scrim{background:#202020cc}}@media(prefers-contrast:more){.tab-bar,.pending-banner-card,.segment,.entry-row{border-color:#20202059}}
 
1
+ :root{color-scheme:light;--bg: #ebe7e0;--bg-elevated: #f1eee8;--card: #f8f6f2;--surface: #f8f6f2;--surface-solid: #f8f6f2;--surface-secondary: #f1eee8;--text: #202020;--text-secondary: #666666;--text-tertiary: #9a9a9a;--separator: rgba(32, 32, 32, .08);--accent: #e8a317;--accent-soft: rgba(232, 163, 23, .16);--ok: #2f6b3c;--ok-bg: #e4f0e6;--warn: #8a6a10;--warn-bg: #f5ebcc;--danger: #8b3a34;--danger-bg: #f0e0de;--pending-bg: #faf3e3;--pending-border: #e8a317;--scrim: rgba(32, 32, 32, .35);--shadow: 0 8px 24px rgba(32, 32, 32, .06);--radius: 20px;--pad: 1.25rem;--hit: 44px;--btn-h: 56px;--fab-size: 60px;--tab-h: 76px;--font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;--safe-top: env(safe-area-inset-top, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);font:400 16px/1.47 var(--font);background:var(--bg);color:var(--text);letter-spacing:0}@media(prefers-contrast:more){:root{--separator: rgba(32, 32, 32, .28);--text-secondary: #444444;--shadow: none}}*,*:before,*:after{box-sizing:border-box}html,body,#app{min-height:100%;margin:0}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;border:none;background:none}a{color:var(--accent);text-decoration:none}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}img{max-width:100%}.material-bar{background:#f8f6f2eb;border-bottom:1px solid var(--separator)}.sheet-material{background:var(--card);border-radius:20px 20px 0 0;box-shadow:0 -8px 40px #2020201f}.surface-card{background:var(--card);border:1px solid var(--separator);border-radius:var(--radius);box-shadow:var(--shadow)}@media(prefers-reduced-transparency:reduce){.material-bar,.sheet-material,.surface-card{backdrop-filter:none;-webkit-backdrop-filter:none;background:var(--surface-solid)}}@media(prefers-contrast:more){.material-bar,.sheet-material,.surface-card{border-color:#20202047;box-shadow:none}}.app-shell{min-height:100dvh;display:flex;flex-direction:column;background:var(--bg)}.top-bar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:.75rem;min-height:calc(var(--hit) + var(--safe-top));padding:calc(.5rem + var(--safe-top)) var(--pad) .75rem}.top-bar h1,.page-title{margin:0;font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.1}.page{flex:1;width:min(40rem,100%);margin:0 auto;padding:1.25rem var(--pad) calc(1.5rem + var(--safe-bottom) + var(--tab-h) + .75rem)}.home-page{gap:16px;animation:card-in .18s ease both}.stack{display:flex;flex-direction:column;gap:16px}.field-label{display:block;margin-bottom:.4rem;color:var(--text-secondary);font-size:13px;font-weight:500}.field-input,.field-textarea{width:100%;min-height:var(--hit);padding:.85rem 1rem;border-radius:16px;border:1px solid var(--separator);background:var(--card);color:var(--text)}.field-textarea{min-height:7rem;resize:vertical;line-height:1.47}.muted{color:var(--text-secondary)}.tertiary{color:var(--text-tertiary)}.error-text{color:var(--danger);font-size:.9rem}.chip-row{display:flex;flex-wrap:wrap;gap:.5rem}.chip{min-height:var(--hit);padding:0 .95rem;border-radius:999px;border:1px solid var(--separator);background:var(--card);color:var(--text-secondary);font-weight:500;display:inline-flex;align-items:center;gap:.35rem}.chip[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--text)}.chip.result-worked[aria-pressed=true]{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}.chip.result-partial[aria-pressed=true]{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}.chip.result-failed[aria-pressed=true]{border-color:var(--danger);background:var(--danger-bg);color:var(--danger)}.chip.result-pending[aria-pressed=true]{border-color:var(--pending-border);background:var(--pending-bg);color:var(--accent)}.slider-wrap{display:grid;gap:.5rem}.slider-wrap input[type=range]{width:100%;accent-color:var(--accent)}.more-block{border-top:1px solid var(--separator);padding-top:1rem}.login-page{min-height:100dvh;display:grid;place-items:center;padding:var(--pad);padding-top:calc(var(--pad) + var(--safe-top));padding-bottom:calc(var(--pad) + var(--safe-bottom));background:var(--bg)}.login-card{width:min(22rem,100%);padding:1.5rem}.login-card h1{margin:0 0 .35rem;font-size:32px;font-weight:700;letter-spacing:-.02em;line-height:1.1}.toast-host{position:fixed;left:0;right:0;top:calc(.75rem + var(--safe-top));z-index:50;display:flex;justify-content:center;pointer-events:none;padding:0 var(--pad)}.toast{pointer-events:auto;max-width:24rem;width:100%;padding:.85rem 1rem;border-radius:16px;background:var(--card);border:1px solid var(--separator);box-shadow:var(--shadow);color:var(--text);font-weight:500}.toast.error{border-color:var(--danger);background:var(--danger-bg)}.pressable{touch-action:manipulation;-webkit-tap-highlight-color:transparent;transform-origin:center}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:var(--btn-h);padding:0 1.25rem;border-radius:999px;font-weight:600}.btn:disabled{opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);color:#202020}.btn-secondary{background:var(--card);border:1px solid var(--separator);color:var(--text)}.btn-destructive{background:var(--danger-bg);color:var(--danger);border:1px solid rgba(139,58,52,.25)}.btn-plain{color:var(--text-secondary);min-height:var(--hit);padding:0 .35rem;justify-content:flex-start;font-weight:500}.btn-large{min-height:var(--btn-h);width:100%;font-size:1.0625rem}.tab-bar{position:fixed;left:0;right:0;bottom:0;z-index:30;min-height:calc(var(--tab-h) + var(--safe-bottom));padding-bottom:var(--safe-bottom);background:var(--card);border-top:1px solid var(--separator)}.tab-bar-inner{display:grid;grid-template-columns:1fr 1fr 60px 1fr 1fr;align-items:stretch;min-height:var(--tab-h);padding:0 .35rem}.tab-item{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.2rem;min-height:var(--tab-h);color:var(--text-tertiary);font-size:11px;font-weight:500;letter-spacing:.01em}.tab-item.active{color:var(--text);font-weight:600}.tab-item.active svg{color:var(--accent)}.tab-fab-slot{width:var(--fab-size)}.tab-fab{position:absolute;left:50%;top:-10px;transform:translate(-50%);width:var(--fab-size);height:var(--fab-size);border-radius:999px;background:var(--accent);box-shadow:0 10px 24px #e8a31759;display:grid;place-items:center}.home-greeting{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.greeting-title{margin:0;font-size:32px;font-weight:700;letter-spacing:-.02em;line-height:1.1}.greeting-sub{margin:.35rem 0 0;color:var(--text-secondary);font-size:15px}.avatar-circle{width:48px;height:48px;border-radius:999px;background:var(--accent-soft);color:var(--text);display:grid;place-items:center;font-weight:700;font-size:14px;flex-shrink:0}.week-calendar{display:grid;grid-template-columns:repeat(7,1fr);gap:.35rem}.week-cal-cell{display:grid;justify-items:center;gap:.25rem;padding:.55rem .2rem;border-radius:16px;background:transparent}.week-cal-cell.today{background:var(--accent);color:#202020}.week-cal-label{font-size:11px;font-weight:600;color:inherit;opacity:.7}.week-cal-num{font-size:15px;font-weight:700}.week-cal-dot{width:6px;height:6px;border-radius:999px;background:transparent}.week-cal-cell.filled .week-cal-dot{background:var(--accent)}.week-cal-cell.today.filled .week-cal-dot,.week-cal-cell.today .week-cal-dot{background:#202020}.pending-banner-card{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:.75rem;min-height:100px;padding:.75rem 1rem;border-radius:var(--radius);background:var(--pending-bg);border:1.5px solid var(--pending-border);text-align:left;box-shadow:var(--shadow)}.pending-illu{width:88px;height:auto}.pending-copy{display:grid;gap:.15rem}.pending-copy strong{font-size:18px;font-weight:600}.pending-copy span{color:var(--text-secondary);font-size:14px}.hero-score-card{display:grid;grid-template-columns:1fr 38%;align-items:center;gap:.5rem;padding:18px;overflow:hidden}.hero-score-label{color:var(--text-secondary);font-size:14px;font-weight:500}.hero-score-value{margin:.15rem 0;font-size:54px;font-weight:700;letter-spacing:-.03em;line-height:1}.hero-score-sub{margin:0;color:var(--text-secondary);font-size:14px}.hero-illu{width:100%;height:auto;justify-self:end}.week-progress-card{display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:center;padding:16px 18px}.week-progress-row{display:flex;justify-content:space-between;gap:.75rem;margin-bottom:.45rem;color:var(--text-secondary);font-size:14px}.week-progress-row strong{color:var(--text);font-size:18px}.progress-track{height:8px;border-radius:999px;background:var(--surface-secondary);overflow:hidden;margin-top:.35rem}.progress-fill{height:100%;border-radius:inherit;background:var(--accent)}.scores-illu{width:72px;height:auto}.section-title{margin:0;font-size:20px;font-weight:600}.entry-list{display:grid;gap:.65rem}.entry-row{display:grid;grid-template-columns:auto 1fr;gap:.75rem;align-items:start;padding:16px;min-height:var(--hit);text-align:left;border-radius:var(--radius);background:var(--card);border:1px solid var(--separator);box-shadow:var(--shadow)}.entry-row.pending-row{background:var(--pending-bg);border-color:var(--pending-border)}.entry-row-main{display:grid;gap:.25rem;min-width:0}.entry-preview{color:var(--text);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.entry-row time{color:var(--text-tertiary);font-size:13px}.result-chip{display:inline-flex;align-items:center;gap:.25rem;padding:.3rem .55rem;border-radius:999px;font-size:12px;font-weight:600;text-transform:capitalize;white-space:nowrap}.result-chip.result-worked{background:var(--ok-bg);color:var(--ok)}.result-chip.result-partial{background:var(--warn-bg);color:var(--warn)}.result-chip.result-failed{background:var(--danger-bg);color:var(--danger)}.result-chip.result-pending{background:var(--pending-bg);color:var(--accent);border:1px solid var(--pending-border)}.quick-actions{display:flex;flex-wrap:wrap;gap:.5rem}.quick-chip{min-height:40px;padding:0 1rem;border-radius:999px;background:var(--surface-secondary);color:var(--text);font-weight:500;font-size:14px}.search-field{position:relative;display:block}.search-field svg{position:absolute;left:.9rem;top:50%;transform:translateY(-50%);color:var(--text-tertiary);pointer-events:none}.search-input{padding-left:2.4rem}.empty-state{padding:2rem 1.25rem;text-align:center;display:grid;gap:.75rem;justify-items:center}.detail-card,.form-card,.stats-card,.settings-card,.coach-result,.score-card{padding:16px 18px}.detail-snippet{margin:0;font-size:18px;font-weight:600;line-height:1.35}.meta-grid{display:grid;gap:.75rem}.section-head{display:flex;align-items:center;justify-content:space-between}.linkish{display:inline-flex;align-items:center;gap:.15rem;color:var(--accent);font-weight:600;font-size:14px;background:none;border:none;padding:0}.pending-focus{border:1.5px solid var(--pending-border);background:var(--pending-bg)}.resolve-snippet{padding:1rem}.resolve-activity{margin:0 0 .35rem;font-size:13px}.resolve-sticky{position:sticky;bottom:0;padding-top:.5rem;padding-bottom:.25rem;background:linear-gradient(transparent,var(--card) 28%)}.sheet-tall{max-height:85dvh!important}.gate-loading{min-height:100dvh;display:grid;place-items:center;color:var(--text-secondary);background:var(--bg)}.top-action{margin-left:auto;min-width:var(--hit);min-height:var(--hit);color:var(--accent);font-weight:600}.sheet-layer{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100}.sheet-scrim{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;background:#202020;opacity:0}.sheet-panel{position:absolute;left:0;right:0;bottom:0;max-height:min(82dvh,44rem);overflow:auto;padding-bottom:var(--safe-bottom);will-change:transform}.sheet-handle-zone{position:sticky;top:0;z-index:1;display:grid;justify-items:center;gap:.45rem;padding:.5rem var(--pad) .75rem;background:inherit;touch-action:none;-webkit-user-select:none;user-select:none}.sheet-handle{width:2.25rem;height:.3rem;border-radius:99px;background:var(--text-tertiary)}.sheet-handle-zone h2{margin:0;font-size:20px;font-weight:600}.sheet-content{padding:0 var(--pad) 1.25rem}.menu-list{display:grid}.menu-row{min-height:3.25rem;width:100%;text-align:left;border-bottom:1px solid var(--separator);color:var(--text)}.destructive-text{color:var(--danger)}.result-label{font-size:.75rem;font-weight:600;text-transform:capitalize}.result-worked{color:var(--ok)}.result-partial{color:var(--warn)}.result-failed{color:var(--danger)}.result-pending{color:var(--accent)}.detail-card p,.coach-text{margin:0;white-space:pre-wrap}.result-heading,.status-row,.score-card,.toggle-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem}.source-badge{padding:.2rem .55rem;border-radius:99px;font-size:.75rem;font-weight:700}.source-badge.rules{background:var(--warn-bg);color:var(--warn)}.source-badge.model{background:var(--accent-soft);color:#8a6408}.flags{color:var(--text-tertiary);font-size:.68rem;text-align:right}.pick-list{display:grid;overflow:hidden;border-radius:var(--radius);background:var(--surface-secondary)}.pick-row{display:flex;justify-content:space-between;gap:.75rem;min-height:3rem;padding:.65rem .85rem;text-align:left;border-bottom:1px solid var(--separator)}.pick-row span:last-child{white-space:nowrap;font-size:.75rem}.disclaimer{font-size:.8rem;text-align:center}.coach-input{min-height:9rem}.segment-row{display:flex;flex-wrap:wrap;gap:.4rem}.segment{min-width:var(--hit);min-height:var(--hit);padding:0 .75rem;border:1px solid var(--separator);border-radius:999px;color:var(--text-secondary);background:var(--card)}.segment[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--text);font-weight:600}.toggle-row{min-height:var(--hit)}.toggle-row input{width:1.4rem;height:1.4rem;accent-color:var(--accent)}.score-card>div{display:grid}.score-card strong,.score-readout{font-size:1.5rem;font-weight:700}.stats-card{display:grid;gap:.85rem}.outcome-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem}.outcome-grid div{display:grid;text-align:center;gap:.2rem}.outcome-grid strong,.large-number{font-size:1.5rem}.stat-row{display:grid;gap:.35rem}.stat-row>div:first-child{display:flex;justify-content:space-between;gap:.5rem}.bar-track{height:.35rem;border-radius:99px;background:var(--surface-secondary);overflow:hidden}.bar-track span{display:block;height:100%;border-radius:inherit;background:var(--accent)}.install-steps{margin:0;padding-left:1.4rem;display:grid;gap:.5rem}.daily-header{justify-content:space-between;align-items:center}.daily-date{font-size:13px;color:var(--text-secondary)}.daily-points{font-size:1.35rem;font-weight:700;color:var(--warn)}.tip-card{position:relative;display:flex;align-items:flex-start;gap:.5rem;padding:12px 44px 12px 16px;border-radius:var(--radius);background:var(--accent-soft);font-size:14px;color:var(--text-secondary)}.tip-card p{margin:0}.indoors-nudge{flex-direction:column;align-items:flex-start;padding-right:16px;gap:.35rem;background:#f3e8d8;color:var(--text)}.warning-chip{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;padding:.2rem .5rem;border-radius:6px;background:#e8a31733;color:#8a5a00}.tip-dismiss{position:absolute;top:6px;right:6px;display:grid;place-items:center;width:32px;height:32px;border-radius:999px;color:var(--text-secondary)}.choice-list{display:grid;gap:.5rem}.choice-item{min-height:var(--hit);padding:.7rem 1rem;text-align:left;border:1px solid var(--separator);border-radius:14px;background:var(--card);color:var(--text-secondary);font-size:15px}.choice-item[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--text);font-weight:600}.stepper-row{display:flex;align-items:center;gap:1rem}.stepper-btn{width:48px;height:48px;border-radius:999px;border:1px solid var(--separator);background:var(--card);font-size:1.4rem;line-height:1}.stepper-btn[disabled]{opacity:.4}.stepper-value{min-width:2.5rem;text-align:center;font-size:1.6rem;font-weight:700}.points-breakdown{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.points-breakdown li{display:flex;justify-content:space-between;gap:.75rem;font-size:14px}.points-breakdown li.missed{color:var(--text-tertiary)}.points-breakdown .points-pts{font-weight:700}.points-breakdown li.earned .points-pts{color:var(--ok)}.how-points{padding:14px 18px;font-size:14px;color:var(--text-secondary)}.how-points summary{cursor:pointer;font-weight:600;color:var(--text);min-height:var(--hit);display:flex;align-items:center}.how-points ul{margin:.5rem 0 0;padding-left:1.2rem;display:grid;gap:.4rem}.daily-sticky{position:sticky;bottom:0;padding:.5rem 0 .25rem;background:linear-gradient(to top,var(--bg) 70%,transparent)}.plan-header{flex-wrap:wrap;gap:.5rem}.plan-day-nav{display:flex;align-items:center;gap:.35rem}.icon-btn{width:44px;height:44px;display:grid;place-items:center;border-radius:999px;color:var(--text)}.chip-btn{min-height:36px;padding:0 .85rem;border-radius:999px;background:var(--accent-soft);color:var(--text);font-weight:600;font-size:13px}.plan-meta-row{display:flex;justify-content:space-between;gap:.75rem}.caption{font-size:13px;color:var(--text-secondary)}.plan-actions{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}.timeline-card{padding:12px 8px 12px 0;overflow:hidden}.timeline{position:relative;display:grid;grid-template-columns:56px 1fr}.timeline-gutter{position:relative}.timeline-hour-label{position:absolute;right:8px;transform:translateY(-50%);font-size:11px;color:var(--text-tertiary)}.timeline-canvas{position:relative;border-left:1px solid var(--separator)}.timeline-hour-line{position:absolute;left:0;right:0;height:1px;background:var(--separator)}.timeline-now{position:absolute;left:0;right:0;height:2px;background:var(--accent);z-index:3}.timeline-now-dot{position:absolute;left:-5px;top:-4px;width:10px;height:10px;border-radius:999px;background:var(--accent)}.timeline-block{position:absolute;left:8px;right:8px;z-index:2;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:.1rem;padding:6px 10px;border-radius:12px;border:1px solid transparent;text-align:left;overflow:hidden}.timeline-block strong{font-size:13px;font-weight:600}.timeline-block span{font-size:11px;opacity:.85}.timeline-block.duty{background:#e8eef3;color:#243447}.timeline-block.explore{background:#e5f3ea;color:#1f4d32}.timeline-block.restore{background:#f3e8f0;color:#4a2f45}.timeline-block.stabilize{background:#f7ecd8;color:#5a4418}.timeline-block.flex{background:var(--surface-secondary);color:var(--text)}.timeline-block.p0{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.timeline-block.done{opacity:.55}.timeline-block.skipped{opacity:.4;text-decoration:line-through}.plan-time-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.btn-secondary{min-height:var(--btn-h);width:100%;border-radius:999px;background:var(--card);border:1px solid var(--separator);color:var(--text);font-weight:600}.meta-line{margin:0;color:var(--text-secondary);font-size:14px}.empty-card{text-align:center}@keyframes card-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.home-page,.surface-card,.entry-row,.pending-banner-card{animation:none!important;transition:opacity .2s ease}.toast{transition:opacity .2s ease}}@media(prefers-reduced-transparency:reduce){.sheet-scrim{background:#202020cc}}@media(prefers-contrast:more){.tab-bar,.pending-banner-card,.segment,.entry-row{border-color:#20202059}}
static/assets/index-Cnlk1HOn.js ADDED
The diff for this file is too large to render. See raw diff
 
static/assets/index-Cnlk1HOn.js.map ADDED
The diff for this file is too large to render. See raw diff
 
static/assets/index-CsRld2Dr.js DELETED
The diff for this file is too large to render. See raw diff
 
static/assets/index-CsRld2Dr.js.map DELETED
The diff for this file is too large to render. See raw diff
 
static/index.html CHANGED
@@ -20,8 +20,8 @@
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-CsRld2Dr.js"></script>
24
- <link rel="stylesheet" crossorigin href="/assets/index-6RturTFo.css">
25
  </head>
26
  <body>
27
  <div id="app"></div>
 
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-Cnlk1HOn.js"></script>
24
+ <link rel="stylesheet" crossorigin href="/assets/index-CK7MU-TQ.css">
25
  </head>
26
  <body>
27
  <div id="app"></div>
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-6RturTFo.css","/assets/index-CsRld2Dr.js","/assets/index-CsRld2Dr.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-Cnlk1HOn.js","/assets/index-Cnlk1HOn.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_backup.py CHANGED
@@ -36,3 +36,16 @@ def test_crisis_ignores_server_pick() -> None:
36
  assert rule_id == "crisis"
37
  assert "walk outside" not in text
38
  assert "crisis" in text.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  assert rule_id == "crisis"
37
  assert "walk outside" not in text
38
  assert "crisis" in text.lower()
39
+
40
+
41
+ def test_high_intensity_forces_interrupt_not_label() -> None:
42
+ text, rule_id = backup_reply(
43
+ "I tried labeling",
44
+ ["shame"],
45
+ [{"remedy_key": "Labeling", "pick": 0.9, "n": 20, "p_helped": 0.2}],
46
+ data_thin=False,
47
+ intensity=8,
48
+ )
49
+ assert rule_id == "high_intensity_fse"
50
+ assert "Labeling" not in text
51
+ assert "interrupt" in text.lower() or "Leave the room" in text
tests/test_daily_points.py CHANGED
@@ -80,3 +80,36 @@ def test_week_band() -> None:
80
  assert week_band(7, 27) == "mixed"
81
  assert week_band(7, 18) == "mixed"
82
  assert week_band(7, 17) == "escape_heavy"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  assert week_band(7, 27) == "mixed"
81
  assert week_band(7, 18) == "mixed"
82
  assert week_band(7, 17) == "escape_heavy"
83
+
84
+
85
+ def test_indoors_streak() -> None:
86
+ from datetime import date, datetime, timezone
87
+
88
+ from app.models import DailyRow
89
+ from app.store_daily import indoors_streak, resolve_movement_minutes
90
+ from app.models import DailyUpsert
91
+
92
+ def row(day: str, indoors: bool) -> DailyRow:
93
+ return DailyRow(
94
+ date=date.fromisoformat(day),
95
+ stayed_indoors_all_day=indoors,
96
+ points=0,
97
+ updated_at=datetime.now(timezone.utc),
98
+ )
99
+
100
+ rows = [
101
+ row("2026-07-20", True),
102
+ row("2026-07-21", True),
103
+ row("2026-07-22", True),
104
+ ]
105
+ assert indoors_streak(rows, as_of=date(2026, 7, 22)) == 3
106
+ assert indoors_streak(rows, as_of=date(2026, 7, 21)) == 2
107
+ rows.append(row("2026-07-19", False))
108
+ assert indoors_streak(rows, as_of=date(2026, 7, 22)) == 3
109
+
110
+ data = DailyUpsert(
111
+ interrupt_walk_minutes=10,
112
+ fantasy_walk_minutes=15,
113
+ movement_minutes=0,
114
+ )
115
+ assert resolve_movement_minutes(data) == 25