Spaces:
Sleeping
Sleeping
Add loop-break daily fields, proof wizard, and high-intensity coach interrupts.
Browse files- .gitignore +1 -0
- app/backup_replies.py +76 -3
- app/coach_service.py +15 -0
- app/models.py +34 -0
- app/routers/agent.py +3 -0
- app/schedule_templates.py +12 -0
- app/store_daily.py +96 -1
- frontend/src/api.ts +20 -0
- frontend/src/pages/Daily.tsx +209 -0
- frontend/src/pages/Home.tsx +74 -1
- frontend/src/pages/Log.tsx +232 -11
- frontend/src/styles/app.css +19 -0
- scripts/pull_handoff_bundle.py +614 -0
- static/assets/{index-6RturTFo.css → index-CK7MU-TQ.css} +1 -1
- static/assets/index-Cnlk1HOn.js +0 -0
- static/assets/index-Cnlk1HOn.js.map +0 -0
- static/assets/index-CsRld2Dr.js +0 -0
- static/assets/index-CsRld2Dr.js.map +0 -0
- static/index.html +2 -2
- static/sw.js +1 -1
- tests/test_backup.py +13 -0
- tests/test_daily_points.py +33 -0
.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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
|
| 3 |
import { useEffect, useMemo, useState } from "preact/hooks";
|
| 4 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
} else {
|
| 86 |
-
|
| 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 |
-
|
| 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-
|
| 24 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 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-
|
| 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
|