personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace todo time field with nudge datetime and notifications

+590 -84
+1
apps/home/routes.py
··· 115 115 { 116 116 "text": todo.get("text", ""), 117 117 "completed": todo.get("completed", False), 118 + "nudge": todo.get("nudge"), 118 119 } 119 120 ) 120 121 if todo.get("completed"):
+47 -1
apps/home/workspace.html
··· 273 273 color: #6b7280; 274 274 } 275 275 276 + .home-todo-nudge { 277 + flex-shrink: 0; 278 + font-size: 0.75rem; 279 + color: #6b7280; 280 + margin-left: auto; 281 + font-variant-numeric: tabular-nums; 282 + } 283 + 284 + .home-todo-item.has-nudge { 285 + display: flex; 286 + align-items: center; 287 + gap: 0.5rem; 288 + } 289 + 276 290 /* Event bar chart styles */ 277 291 .home-event-list { 278 292 display: flex; ··· 479 493 return new Date(year, month, day).toLocaleDateString(undefined, { weekday: 'short' }); 480 494 } 481 495 496 + function formatHomeNudge(nudge) { 497 + // Parse YYYYMMDDTHH:MM 498 + const year = parseInt(nudge.slice(0, 4)); 499 + const month = parseInt(nudge.slice(4, 6)) - 1; 500 + const day = parseInt(nudge.slice(6, 8)); 501 + const hour = parseInt(nudge.slice(9, 11)); 502 + const minute = parseInt(nudge.slice(12, 14)); 503 + const nudgeDate = new Date(year, month, day, hour, minute); 504 + const now = new Date(); 505 + const diffMs = now - nudgeDate; 506 + 507 + if (diffMs > 0) { 508 + const mins = Math.floor(diffMs / 60000); 509 + const hours = Math.floor(diffMs / 3600000); 510 + const days = Math.floor(diffMs / 86400000); 511 + if (mins < 1) return 'just now'; 512 + if (mins < 60) return `${mins}m ago`; 513 + if (hours < 24) return `${hours}h ago`; 514 + if (days === 1) return 'yesterday'; 515 + return `${days}d ago`; 516 + } else { 517 + const timeStr = nudge.slice(9); 518 + const today = now.toISOString().slice(0, 10).replace(/-/g, ''); 519 + const tomorrow = new Date(now.getTime() + 86400000).toISOString().slice(0, 10).replace(/-/g, ''); 520 + const nudgeDay = nudge.slice(0, 8); 521 + if (nudgeDay === today) return timeStr; 522 + if (nudgeDay === tomorrow) return `tomorrow ${timeStr}`; 523 + return `${nudgeDate.toLocaleDateString('en-US', {month: 'short', day: 'numeric'})} ${timeStr}`; 524 + } 525 + } 526 + 482 527 function renderTodos(data, facet) { 483 528 const container = document.getElementById('todos-content'); 484 529 let todos = []; ··· 508 553 container.innerHTML = ` 509 554 <ul class="home-todo-list"> 510 555 ${todos.map(todo => ` 511 - <li class="home-todo-item ${todo.completed ? 'completed' : ''}"> 556 + <li class="home-todo-item ${todo.completed ? 'completed' : ''}${todo.nudge ? ' has-nudge' : ''}"> 512 557 <input type="checkbox" class="home-todo-checkbox" ${todo.completed ? 'checked' : ''} disabled> 513 558 <span class="home-todo-text" title="${escapeHtml(todo.text)}">${escapeHtml(todo.text)}</span> 559 + ${todo.nudge ? `<span class="home-todo-nudge">${escapeHtml(formatHomeNudge(todo.nudge))}</span>` : ''} 514 560 </li> 515 561 `).join('')} 516 562 </ul>
+48
apps/todos/background.html
··· 1 1 AppServices.register('todos', { 2 + _nudgeTimers: [], 3 + 2 4 initialize() { 3 5 this.updateBadge(); 6 + this.checkNudges(); 4 7 }, 5 8 6 9 async updateBadge() { ··· 17 20 } catch (err) { 18 21 console.error('[Todos] Failed to fetch badge count:', err); 19 22 } 23 + }, 24 + 25 + async checkNudges() { 26 + try { 27 + const response = await fetch('/app/todos/api/nudges'); 28 + if (!response.ok) return; 29 + const data = await response.json(); 30 + 31 + // Clear existing timers 32 + this._nudgeTimers.forEach(t => clearTimeout(t)); 33 + this._nudgeTimers = []; 34 + 35 + const now = Date.now(); 36 + for (const nudge of data.nudges) { 37 + const nudgeTime = this._parseNudge(nudge.nudge); 38 + const delay = nudgeTime - now; 39 + 40 + if (delay > 0 && delay < 24 * 60 * 60 * 1000) { 41 + const timer = setTimeout(() => { 42 + AppServices.notifications.show({ 43 + app: 'todos', 44 + icon: '✅', 45 + title: 'Todo Reminder', 46 + message: nudge.text, 47 + action: `/app/todos/${nudge.day}`, 48 + facet: nudge.facet, 49 + autoDismiss: 30000, 50 + }); 51 + }, delay); 52 + this._nudgeTimers.push(timer); 53 + } 54 + } 55 + } catch (err) { 56 + console.error('[Todos] Failed to check nudges:', err); 57 + } 58 + }, 59 + 60 + _parseNudge(nudgeStr) { 61 + // YYYYMMDDTHH:MM -> timestamp 62 + const year = parseInt(nudgeStr.slice(0, 4)); 63 + const month = parseInt(nudgeStr.slice(4, 6)) - 1; 64 + const day = parseInt(nudgeStr.slice(6, 8)); 65 + const hour = parseInt(nudgeStr.slice(9, 11)); 66 + const minute = parseInt(nudgeStr.slice(12, 14)); 67 + return new Date(year, month, day, hour, minute).getTime(); 20 68 } 21 69 });
+91 -1
apps/todos/call.py
··· 95 95 facet: str | None = typer.Option( 96 96 None, "--facet", "-f", help="Facet name (or set SOL_FACET)." 97 97 ), 98 + nudge: str | None = typer.Option( 99 + None, 100 + "--nudge", 101 + "-n", 102 + help="Nudge time: HH:MM, now, tomorrow HH:MM, or YYYYMMDDTHH:MM.", 103 + ), 98 104 ) -> None: 99 105 """Add a new todo item.""" 100 106 from datetime import datetime ··· 111 117 typer.echo(f"Error: invalid day format '{day}'", err=True) 112 118 raise typer.Exit(1) 113 119 120 + # Parse nudge if provided 121 + parsed_nudge: str | None = None 122 + if nudge is not None: 123 + try: 124 + parsed_nudge = todo.parse_nudge(nudge, day) 125 + except ValueError as exc: 126 + typer.echo(f"Error: {exc}", err=True) 127 + raise typer.Exit(1) from None 128 + 114 129 try: 115 130 116 131 def _add(checklist: todo.TodoChecklist) -> todo.TodoChecklist: 117 - checklist.append_entry(text) 132 + checklist.append_entry(text, nudge=parsed_nudge) 118 133 return checklist 119 134 120 135 checklist = todo.TodoChecklist.locked_modify(day, facet, _add) ··· 225 240 226 241 result = todo.upcoming(limit=limit, facet=facet) 227 242 typer.echo(result) 243 + 244 + 245 + @app.command("check-nudges") 246 + def check_nudges( 247 + facet: str | None = typer.Option( 248 + None, 249 + "--facet", 250 + "-f", 251 + help="Facet name (or set SOL_FACET). Omit to check all facets.", 252 + ), 253 + ) -> None: 254 + """Check for un-notified past nudges and send notifications.""" 255 + import subprocess 256 + from datetime import datetime 257 + from pathlib import Path 258 + 259 + from think.utils import get_journal, resolve_sol_facet 260 + 261 + journal = get_journal() 262 + today = datetime.now().strftime("%Y%m%d") 263 + now_str = datetime.now().strftime("%Y%m%dT%H:%M") 264 + 265 + facets_dir = Path(journal) / "facets" 266 + if not facets_dir.is_dir(): 267 + return 268 + 269 + if facet is not None: 270 + facet = resolve_sol_facet(facet) 271 + facet_names = [facet] 272 + else: 273 + facet_names = [d.name for d in facets_dir.iterdir() if d.is_dir()] 274 + 275 + for facet_name in facet_names: 276 + checklist = todo.TodoChecklist.load(today, facet_name) 277 + if not checklist.exists: 278 + continue 279 + 280 + modified = False 281 + for item in checklist.items: 282 + if ( 283 + item.nudge 284 + and item.nudge <= now_str 285 + and not item.notified 286 + and not item.completed 287 + and not item.cancelled 288 + ): 289 + # Send notification via sol notify 290 + try: 291 + subprocess.run( 292 + [ 293 + "sol", 294 + "notify", 295 + item.text, 296 + "--title", 297 + "Todo Reminder", 298 + "--icon", 299 + "✅", 300 + "--app", 301 + "todos", 302 + "--facet", 303 + facet_name, 304 + "--action", 305 + f"/app/todos/{today}", 306 + ], 307 + check=False, 308 + capture_output=True, 309 + ) 310 + except FileNotFoundError: 311 + pass # sol not available 312 + item.notified = True 313 + modified = True 314 + typer.echo(f"Notified: [{facet_name}] {item.text}") 315 + 316 + if modified: 317 + checklist.save()
+83
apps/todos/maint/000_time_to_nudge.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Migrate todo time field to nudge format. 5 + 6 + Scans all facets/*/todos/*.jsonl files and converts any "time" field 7 + to the new "nudge" format (YYYYMMDDTHH:MM), using the filename day. 8 + """ 9 + 10 + import argparse 11 + import json 12 + import logging 13 + from pathlib import Path 14 + 15 + from think.utils import get_journal, setup_cli 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + def main(): 21 + parser = argparse.ArgumentParser(description="Migrate todo time → nudge") 22 + setup_cli(parser) 23 + 24 + journal = Path(get_journal()) 25 + facets_dir = journal / "facets" 26 + 27 + if not facets_dir.is_dir(): 28 + print("No facets directory found.") 29 + return 30 + 31 + migrated_files = 0 32 + migrated_entries = 0 33 + 34 + for facet_dir in sorted(facets_dir.iterdir()): 35 + if not facet_dir.is_dir(): 36 + continue 37 + todos_dir = facet_dir / "todos" 38 + if not todos_dir.is_dir(): 39 + continue 40 + 41 + for jsonl_file in sorted(todos_dir.glob("*.jsonl")): 42 + stem = jsonl_file.stem 43 + if len(stem) != 8 or not stem.isdigit(): 44 + continue 45 + 46 + day = stem 47 + lines = jsonl_file.read_text(encoding="utf-8").splitlines() 48 + new_lines = [] 49 + file_changed = False 50 + 51 + for line in lines: 52 + stripped = line.strip() 53 + if not stripped: 54 + new_lines.append(line) 55 + continue 56 + try: 57 + data = json.loads(stripped) 58 + except json.JSONDecodeError: 59 + new_lines.append(line) 60 + continue 61 + 62 + if "time" in data and "nudge" not in data: 63 + time_val = data.pop("time") 64 + data["nudge"] = f"{day}T{time_val}" 65 + new_lines.append(json.dumps(data, ensure_ascii=False)) 66 + file_changed = True 67 + migrated_entries += 1 68 + else: 69 + new_lines.append(line) 70 + 71 + if file_changed: 72 + content = "\n".join(new_lines) 73 + if new_lines and not content.endswith("\n"): 74 + content += "\n" 75 + jsonl_file.write_text(content, encoding="utf-8") 76 + migrated_files += 1 77 + logger.info("Migrated %s", jsonl_file) 78 + 79 + print(f"Migration complete: {migrated_entries} entries in {migrated_files} files.") 80 + 81 + 82 + if __name__ == "__main__": 83 + main()
+52 -8
apps/todos/routes.py
··· 20 20 from apps.todos.todo import ( 21 21 TodoChecklist, 22 22 TodoEmptyTextError, 23 + format_nudge, 23 24 get_todos, 24 25 ) 25 26 from apps.utils import log_app_action ··· 90 91 return jsonify({"count": total}) 91 92 92 93 94 + @todos_bp.route("/api/nudges") 95 + def nudges_api(): 96 + """Return todos with future nudges for today and tomorrow.""" 97 + now = datetime.now() 98 + today = now.strftime("%Y%m%d") 99 + tomorrow = (now + timedelta(days=1)).strftime("%Y%m%d") 100 + now_str = now.strftime("%Y%m%dT%H:%M") 101 + 102 + nudges = [] 103 + facets_dir = Path(state.journal_root) / "facets" 104 + if not facets_dir.is_dir(): 105 + return jsonify({"nudges": []}) 106 + 107 + for facet_dir in facets_dir.iterdir(): 108 + if not facet_dir.is_dir(): 109 + continue 110 + facet_name = facet_dir.name 111 + for day in [today, tomorrow]: 112 + checklist = TodoChecklist.load(day, facet_name) 113 + if not checklist.exists: 114 + continue 115 + for item in checklist.items: 116 + if ( 117 + item.nudge 118 + and item.nudge > now_str 119 + and not item.completed 120 + and not item.cancelled 121 + and not item.notified 122 + ): 123 + nudges.append( 124 + { 125 + "facet": facet_name, 126 + "day": day, 127 + "index": item.index, 128 + "text": item.text, 129 + "nudge": item.nudge, 130 + } 131 + ) 132 + 133 + return jsonify({"nudges": nudges}) 134 + 135 + 93 136 @todos_bp.route("/api/stats/<month>") 94 137 def api_stats(month: str): 95 138 """Return todo counts per facet for a specific month. ··· 216 259 "facet": facet, 217 260 "index": item.index, 218 261 "text": item.text, 219 - "time": item.time, 262 + "nudge": item.nudge, 263 + "nudge_display": format_nudge(item.nudge) 264 + if item.nudge 265 + else None, 220 266 "completed": False, 221 267 }, 222 268 **counts, ··· 325 371 # Add to new facet, preserving original created_at 326 372 new_checklist = TodoChecklist.load(day, new_facet) 327 373 new_item = new_checklist.append_entry( 328 - text, created_at=source_item.created_at 374 + text, 375 + nudge=source_item.nudge, 376 + created_at=source_item.created_at, 329 377 ) 330 378 331 379 # Preserve completed status ··· 437 485 todos_by_facet=sorted_todos_by_facet, 438 486 facet_map=facet_map, 439 487 facet_counts=facet_counts, 488 + format_nudge=format_nudge, 440 489 ) 441 490 442 491 ··· 501 550 502 551 # Add to target day 503 552 try: 504 - # Reconstruct text with time if present 505 - text = source_item.text 506 - if source_item.time: 507 - text = f"{text} ({source_item.time})" 508 - # Preserve original created_at timestamp when moving 509 553 new_item = target_checklist.append_entry( 510 - text, created_at=source_item.created_at 554 + source_item.text, nudge=source_item.nudge, created_at=source_item.created_at 511 555 ) 512 556 except TodoEmptyTextError as exc: 513 557 current_app.logger.debug("Failed to append todo to %s: %s", target_day, exc)
+20
apps/todos/tests/test_call.py
··· 107 107 ) 108 108 assert result.exit_code == 1 109 109 110 + def test_add_with_nudge(self, todo_env): 111 + """Add a todo with --nudge flag.""" 112 + todo_env(day="20260301", facet="personal") 113 + result = runner.invoke( 114 + call_app, 115 + [ 116 + "todos", 117 + "add", 118 + "Test nudge", 119 + "--nudge", 120 + "15:00", 121 + "-f", 122 + "personal", 123 + "-d", 124 + "20260301", 125 + ], 126 + ) 127 + assert result.exit_code == 0 128 + assert "Test nudge" in result.output 129 + 110 130 111 131 class TestTodosDone: 112 132 """Tests for 'sol call todos done' command."""
+111 -23
apps/todos/tests/test_todo.py
··· 48 48 "personal", 49 49 "20240102", 50 50 [ 51 - {"text": "Merge analytics PR", "time": "10:30"}, 52 - {"text": "Project sync", "completed": True}, # no time 51 + {"text": "Merge analytics PR", "nudge": "20240102T10:30"}, 52 + {"text": "Project sync", "completed": True}, # no nudge 53 53 {"text": "Write retrospective notes"}, 54 54 ], 55 55 ) ··· 60 60 61 61 first = todos[0] 62 62 assert first["index"] == 1 63 - assert first["time"] == "10:30" 63 + assert first["nudge"] == "20240102T10:30" 64 64 assert first["completed"] is False 65 65 assert first["text"] == "Merge analytics PR" 66 66 67 67 second = todos[1] 68 68 assert second["completed"] is True 69 - assert second["time"] is None 69 + assert second["nudge"] is None 70 70 assert second["text"] == "Project sync" 71 71 72 72 third = todos[2] ··· 82 82 "20240103", 83 83 [ 84 84 {"text": "Optional experiment", "cancelled": True}, 85 - {"text": "Address bug", "time": "14:45"}, 85 + {"text": "Address bug", "nudge": "20240103T14:45"}, 86 86 {"text": "Draft report", "completed": True}, 87 87 ], 88 88 ) ··· 96 96 assert cancelled["text"] == "Optional experiment" 97 97 98 98 second = todos[1] 99 - assert second["time"] == "14:45" 99 + assert second["nudge"] == "20240103T14:45" 100 100 assert second["index"] == 2 101 101 102 102 third = todos[2] ··· 122 122 def test_todo_item_display_line(): 123 123 """Test TodoItem.display_line() formatting.""" 124 124 item = TodoItem( 125 - index=1, text="Simple task", time=None, completed=False, cancelled=False 125 + index=1, text="Simple task", nudge=None, completed=False, cancelled=False 126 126 ) 127 127 assert item.display_line() == "[ ] Simple task" 128 128 129 129 item_done = TodoItem( 130 - index=2, text="Done task", time=None, completed=True, cancelled=False 130 + index=2, text="Done task", nudge=None, completed=True, cancelled=False 131 131 ) 132 132 assert item_done.display_line() == "[x] Done task" 133 133 134 134 item_cancelled = TodoItem( 135 - index=3, text="Cancelled task", time=None, completed=False, cancelled=True 135 + index=3, text="Cancelled task", nudge=None, completed=False, cancelled=True 136 136 ) 137 137 assert item_cancelled.display_line() == "~~[cancelled] Cancelled task~~" 138 138 139 - item_with_time = TodoItem( 140 - index=4, text="Meeting", time="14:30", completed=False, cancelled=False 139 + item_with_nudge = TodoItem( 140 + index=4, 141 + text="Meeting", 142 + nudge="20240101T14:30", 143 + completed=False, 144 + cancelled=False, 141 145 ) 142 - assert item_with_time.display_line() == "[ ] Meeting (14:30)" 146 + assert "[ ] Meeting (" in item_with_nudge.display_line() 143 147 144 148 item_cancelled_done = TodoItem( 145 - index=5, text="Was done", time=None, completed=True, cancelled=True 149 + index=5, text="Was done", nudge=None, completed=True, cancelled=True 146 150 ) 147 151 assert item_cancelled_done.display_line() == "~~[cancelled] Was done~~" 148 152 149 153 150 154 def test_todo_item_to_jsonl(): 151 155 """Test TodoItem.to_jsonl() serialization.""" 152 - item = TodoItem(index=1, text="Task", time=None, completed=False, cancelled=False) 156 + item = TodoItem(index=1, text="Task", nudge=None, completed=False, cancelled=False) 153 157 assert item.to_jsonl() == {"text": "Task"} 154 158 155 159 item_full = TodoItem( 156 - index=2, text="Full task", time="10:00", completed=True, cancelled=False 160 + index=2, 161 + text="Full task", 162 + nudge="20240102T10:00", 163 + completed=True, 164 + cancelled=False, 157 165 ) 158 166 assert item_full.to_jsonl() == { 159 167 "text": "Full task", 160 - "time": "10:00", 168 + "nudge": "20240102T10:00", 161 169 "completed": True, 162 170 } 163 171 164 172 item_cancelled = TodoItem( 165 - index=3, text="Cancelled", time=None, completed=False, cancelled=True 173 + index=3, text="Cancelled", nudge=None, completed=False, cancelled=True 166 174 ) 167 175 assert item_cancelled.to_jsonl() == {"text": "Cancelled", "cancelled": True} 168 176 ··· 174 182 assert item.text == "Simple" 175 183 assert item.completed is False 176 184 assert item.cancelled is False 177 - assert item.time is None 185 + assert item.nudge is None 178 186 179 187 item_full = TodoItem.from_jsonl( 180 - {"text": "Full", "time": "10:00", "completed": True, "cancelled": False}, 2 188 + { 189 + "text": "Full", 190 + "nudge": "20240102T10:00", 191 + "completed": True, 192 + "cancelled": False, 193 + }, 194 + 2, 181 195 ) 182 - assert item_full.time == "10:00" 196 + assert item_full.nudge == "20240102T10:00" 183 197 assert item_full.completed is True 184 198 185 199 200 + def test_todo_item_from_jsonl_legacy_time_with_day(): 201 + """Legacy 'time' field should be converted when day context is provided.""" 202 + item = TodoItem.from_jsonl({"text": "Legacy", "time": "10:00"}, 1, day="20240102") 203 + assert item.nudge == "20240102T10:00" 204 + 205 + 206 + class TestParseNudge: 207 + """Tests for parse_nudge().""" 208 + 209 + def test_hhmm(self): 210 + from apps.todos.todo import parse_nudge 211 + 212 + assert parse_nudge("15:00", "20260301") == "20260301T15:00" 213 + 214 + def test_now(self): 215 + from apps.todos.todo import parse_nudge 216 + 217 + result = parse_nudge("now", "20260301") 218 + assert result.startswith("20") 219 + assert "T" in result 220 + 221 + def test_tomorrow(self): 222 + from apps.todos.todo import parse_nudge 223 + 224 + assert parse_nudge("tomorrow 09:00", "20260301") == "20260302T09:00" 225 + 226 + def test_full_datetime(self): 227 + from apps.todos.todo import parse_nudge 228 + 229 + assert parse_nudge("20260315T14:30", "20260301") == "20260315T14:30" 230 + 231 + def test_invalid(self): 232 + from apps.todos.todo import parse_nudge 233 + 234 + with pytest.raises(ValueError): 235 + parse_nudge("garbage", "20260301") 236 + 237 + 238 + class TestFormatNudge: 239 + """Tests for format_nudge().""" 240 + 241 + def test_past_minutes(self): 242 + from datetime import datetime 243 + 244 + from apps.todos.todo import format_nudge 245 + 246 + now = datetime(2026, 3, 1, 15, 30) 247 + assert format_nudge("20260301T15:00", now) == "30m ago" 248 + 249 + def test_past_hours(self): 250 + from datetime import datetime 251 + 252 + from apps.todos.todo import format_nudge 253 + 254 + now = datetime(2026, 3, 1, 18, 0) 255 + assert format_nudge("20260301T15:00", now) == "3h ago" 256 + 257 + def test_future_today(self): 258 + from datetime import datetime 259 + 260 + from apps.todos.todo import format_nudge 261 + 262 + now = datetime(2026, 3, 1, 10, 0) 263 + assert format_nudge("20260301T15:00", now) == "nudge 15:00" 264 + 265 + def test_future_tomorrow(self): 266 + from datetime import datetime 267 + 268 + from apps.todos.todo import format_nudge 269 + 270 + now = datetime(2026, 3, 1, 10, 0) 271 + assert format_nudge("20260302T09:00", now) == "tomorrow 09:00" 272 + 273 + 186 274 def test_upcoming_groups_future_days(monkeypatch, journal_root): 187 275 monkeypatch.setenv("JOURNAL_PATH", str(journal_root)) 188 276 # Create facet structure ··· 352 440 checklist = TodoChecklist.load("20240105", "work") 353 441 354 442 # Test normal entry works 355 - item = checklist.append_entry("Test task (10:30)") 443 + item = checklist.append_entry("Test task", nudge="20240105T10:30") 356 444 assert len(checklist.items) == 1 357 445 assert item.text == "Test task" 358 - assert item.time == "10:30" 446 + assert item.nudge == "20240105T10:30" 359 447 360 448 # Verify file contents 361 449 content = checklist.path.read_text(encoding="utf-8") 362 450 data = json.loads(content.strip()) 363 451 assert data["text"] == "Test task" 364 - assert data["time"] == "10:30" 452 + assert data["nudge"] == "20240105T10:30" 365 453 366 454 367 455 def test_checklist_cancel_entry(monkeypatch, journal_root):
+128 -43
apps/todos/todo.py
··· 16 16 import re 17 17 import time 18 18 from dataclasses import dataclass 19 - from datetime import datetime 19 + from datetime import datetime, timedelta 20 20 from pathlib import Path 21 21 from typing import Any 22 22 ··· 28 28 "TodoItem", 29 29 "TodoError", 30 30 "TodoEmptyTextError", 31 + "parse_nudge", 32 + "format_nudge", 31 33 "get_todos", 32 34 "todo_file_path", 33 35 "validate_line_number", ··· 36 38 "get_todo_days_in_range", 37 39 ] 38 40 39 - # Regex for extracting time annotation from text 40 - TIME_RE = re.compile(r"\((\d{1,2}:[0-5]\d)\)\s*$") 41 + 42 + def parse_nudge(value: str, day: str) -> str: 43 + """Parse nudge input and return normalized YYYYMMDDTHH:MM string. 44 + 45 + Args: 46 + value: One of: "HH:MM", "now", "tomorrow HH:MM", "YYYYMMDDTHH:MM". 47 + day: Current day in YYYYMMDD format, used to resolve relative times. 48 + 49 + Returns: 50 + Normalized nudge string in YYYYMMDDTHH:MM format. 51 + 52 + Raises: 53 + ValueError: If the input format is not recognized. 54 + """ 55 + value = value.strip() 56 + 57 + # "now" — use current time 58 + if value.lower() == "now": 59 + now = datetime.now() 60 + return now.strftime("%Y%m%dT%H:%M") 61 + 62 + # "YYYYMMDDTHH:MM" — already normalized, validate 63 + if len(value) == 14 and value[8] == "T": 64 + date_part = value[:8] 65 + time_part = value[9:] 66 + try: 67 + datetime.strptime(f"{date_part} {time_part}", "%Y%m%d %H:%M") 68 + except ValueError: 69 + raise ValueError(f"invalid nudge datetime: {value}") from None 70 + return value 71 + 72 + # "tomorrow HH:MM" 73 + if value.lower().startswith("tomorrow"): 74 + time_str = value[len("tomorrow") :].strip() 75 + try: 76 + parsed = datetime.strptime(time_str, "%H:%M") 77 + except ValueError: 78 + raise ValueError( 79 + f"invalid nudge time after 'tomorrow': {time_str}" 80 + ) from None 81 + day_date = datetime.strptime(day, "%Y%m%d") + timedelta(days=1) 82 + return f"{day_date.strftime('%Y%m%d')}T{parsed.strftime('%H:%M')}" 83 + 84 + # "HH:MM" — use the provided day 85 + try: 86 + parsed = datetime.strptime(value, "%H:%M") 87 + except ValueError: 88 + raise ValueError(f"unrecognized nudge format: {value}") from None 89 + return f"{day}T{parsed.strftime('%H:%M')}" 90 + 91 + 92 + def format_nudge(nudge: str, now: datetime | None = None) -> str: 93 + """Format a nudge datetime for display. 94 + 95 + Past nudges show age: "3h ago", "yesterday", "2d ago". 96 + Future nudges show scheduled time: "15:00", "tomorrow 09:00". 97 + 98 + Args: 99 + nudge: Nudge string in YYYYMMDDTHH:MM format. 100 + now: Current datetime (defaults to datetime.now()). 101 + 102 + Returns: 103 + Human-readable nudge display string. 104 + """ 105 + if now is None: 106 + now = datetime.now() 107 + 108 + try: 109 + nudge_dt = datetime.strptime(nudge, "%Y%m%dT%H:%M") 110 + except ValueError: 111 + return nudge # fallback: return raw 112 + 113 + delta = now - nudge_dt 114 + total_seconds = delta.total_seconds() 115 + 116 + if total_seconds > 0: 117 + # Past nudge 118 + minutes = int(total_seconds // 60) 119 + hours = int(total_seconds // 3600) 120 + days = int(total_seconds // 86400) 121 + if minutes < 1: 122 + return "just now" 123 + if minutes < 60: 124 + return f"{minutes}m ago" 125 + if hours < 24: 126 + return f"{hours}h ago" 127 + if days == 1: 128 + return "yesterday" 129 + return f"{days}d ago" 130 + 131 + # Future nudge 132 + nudge_day = nudge[:8] 133 + now_day = now.strftime("%Y%m%d") 134 + time_str = nudge[9:] 135 + if nudge_day == now_day: 136 + return f"nudge {time_str}" 137 + # Check if tomorrow 138 + tomorrow = now + timedelta(days=1) 139 + if nudge_day == tomorrow.strftime("%Y%m%d"): 140 + return f"tomorrow {time_str}" 141 + # Further out — show date 142 + return f"{nudge_dt.strftime('%b %-d')} {time_str}" 41 143 42 144 43 145 class TodoError(Exception): ··· 57 159 58 160 index: int 59 161 text: str 60 - time: str | None 162 + nudge: str | None 61 163 completed: bool 62 164 cancelled: bool 63 165 created_at: int | None = None 64 166 updated_at: int | None = None 167 + notified: bool = False 65 168 66 169 def as_dict(self) -> dict[str, object]: 67 170 """Return the item as a JSON-serializable dictionary.""" 68 171 return { 69 172 "index": self.index, 70 173 "text": self.text, 71 - "time": self.time, 174 + "nudge": self.nudge, 72 175 "completed": self.completed, 73 176 "cancelled": self.cancelled, 74 177 "created_at": self.created_at, 75 178 "updated_at": self.updated_at, 179 + "notified": self.notified, 76 180 } 77 181 78 182 def to_jsonl(self) -> dict[str, Any]: 79 183 """Return the item as a JSONL-compatible dictionary for storage.""" 80 184 data: dict[str, Any] = {"text": self.text} 81 - if self.time: 82 - data["time"] = self.time 185 + if self.nudge: 186 + data["nudge"] = self.nudge 83 187 if self.completed: 84 188 data["completed"] = True 85 189 if self.cancelled: 86 190 data["cancelled"] = True 191 + if self.notified: 192 + data["notified"] = True 87 193 if self.created_at is not None: 88 194 data["created_at"] = self.created_at 89 195 if self.updated_at is not None: ··· 91 197 return data 92 198 93 199 @classmethod 94 - def from_jsonl(cls, data: dict[str, Any], index: int) -> "TodoItem": 200 + def from_jsonl( 201 + cls, data: dict[str, Any], index: int, day: str | None = None 202 + ) -> "TodoItem": 95 203 """Create a TodoItem from a JSONL dictionary.""" 204 + nudge = data.get("nudge") 205 + if nudge is None and data.get("time") and day: 206 + nudge = f"{day}T{data['time']}" 96 207 return cls( 97 208 index=index, 98 209 text=data.get("text", ""), 99 - time=data.get("time"), 210 + nudge=nudge, 100 211 completed=data.get("completed", False), 101 212 cancelled=data.get("cancelled", False), 102 213 created_at=data.get("created_at"), 103 214 updated_at=data.get("updated_at"), 215 + notified=data.get("notified", False), 104 216 ) 105 217 106 218 def display_line(self) -> str: ··· 113 225 checkbox = "[ ]" 114 226 115 227 text = self.text 116 - if self.time: 117 - text = f"{text} ({self.time})" 228 + if self.nudge: 229 + text = f"{text} ({format_nudge(self.nudge)})" 118 230 119 231 if self.cancelled: 120 232 return f"~~{checkbox} {text}~~" ··· 171 283 item_index += 1 172 284 try: 173 285 data = json.loads(line) 174 - items.append(TodoItem.from_jsonl(data, item_index)) 286 + items.append(TodoItem.from_jsonl(data, item_index, day=day)) 175 287 except json.JSONDecodeError: 176 288 logging.debug( 177 289 "Skipping malformed JSONL line %d in %s", item_index, path ··· 262 374 return "\n".join(lines) 263 375 264 376 def append_entry( 265 - self, text: str, time: str | None = None, *, created_at: int | None = None 377 + self, text: str, nudge: str | None = None, *, created_at: int | None = None 266 378 ) -> TodoItem: 267 379 """Append a new unchecked todo entry. 268 380 269 381 Args: 270 382 text: Body of the todo item. 271 - time: Optional scheduled time in "HH:MM" format. If not provided, 272 - will be extracted from text if present as (HH:MM) suffix. 383 + nudge: Optional nudge datetime in YYYYMMDDTHH:MM format. 273 384 created_at: Optional creation timestamp to preserve (e.g., when moving todos). 274 - If not provided, uses current time. 275 385 276 386 Returns: 277 387 The newly created TodoItem. 278 388 """ 279 389 body = self._validated_text(text) 280 390 281 - # Extract time from text if not explicitly provided 282 - if time is None: 283 - time_match = TIME_RE.search(body) 284 - if time_match: 285 - hour_str = time_match.group(1).split(":", 1)[0] 286 - try: 287 - if 0 <= int(hour_str) <= 23: 288 - time = time_match.group(1) 289 - body = body[: time_match.start()].rstrip() 290 - except ValueError: 291 - pass 292 - 293 391 now = now_ms() 294 392 item = TodoItem( 295 393 index=len(self.items) + 1, 296 394 text=body, 297 - time=time, 395 + nudge=nudge, 298 396 completed=False, 299 397 cancelled=False, 300 398 created_at=created_at if created_at is not None else now, ··· 366 464 _, item = self._get_item(line_number) 367 465 body = self._validated_text(text) 368 466 369 - # Extract time from new text 370 - time: str | None = None 371 - time_match = TIME_RE.search(body) 372 - if time_match: 373 - hour_str = time_match.group(1).split(":", 1)[0] 374 - try: 375 - if 0 <= int(hour_str) <= 23: 376 - time = time_match.group(1) 377 - body = body[: time_match.start()].rstrip() 378 - except ValueError: 379 - pass 380 - 381 467 item.text = body 382 - item.time = time 383 468 item.updated_at = now_ms() 384 469 self.save() 385 470 return item ··· 683 768 continue 684 769 685 770 # Create TodoItem using existing from_jsonl 686 - item = TodoItem.from_jsonl(entry, i + 1) 771 + item = TodoItem.from_jsonl(entry, i + 1, day=day_str) 687 772 688 773 # Determine timestamp: updated_at -> created_at -> file mtime 689 774 ts = item.updated_at or item.created_at or file_mtime_ms
+4 -4
apps/todos/workspace.html
··· 106 106 color: #6b7280; 107 107 } 108 108 109 - .todo-time { 109 + .todo-nudge { 110 110 flex-shrink: 0; 111 111 font-size: 0.8rem; 112 112 color: #6b7280; ··· 278 278 data-index="{{ item.index }}" 279 279 data-facet="{{ facet_name }}" 280 280 >{{ item.text }}</span> 281 - {% if item.time %} 282 - <span class="todo-time">{{ item.time }}</span> 281 + {% if item.nudge %} 282 + <span class="todo-nudge">{{ format_nudge(item.nudge) }}</span> 283 283 {% endif %} 284 284 </div> 285 285 ··· 1230 1230 data-index="${todo.index}" 1231 1231 data-facet="${facetName}" 1232 1232 >${todo.text}</span> 1233 - ${todo.time ? `<span class="todo-time">${todo.time}</span>` : ''} 1233 + ${todo.nudge_display ? `<span class="todo-nudge">${todo.nudge_display}</span>` : ''} 1234 1234 </div> 1235 1235 1236 1236 <div class="todo-actions">
+1 -1
tests/fixtures/journal/facets/personal/todos/20240101.jsonl
··· 1 1 {"text": "Review project proposal", "completed": true, "created_at": 1704067200000, "updated_at": 1704070800000} 2 - {"text": "Call dentist", "time": "14:00", "completed": false, "created_at": 1704067200000, "updated_at": 1704067200000} 2 + {"text": "Call dentist", "nudge": "20240101T14:00", "completed": false, "created_at": 1704067200000, "updated_at": 1704067200000} 3 3 {"text": "Buy groceries", "cancelled": true, "created_at": 1704067200000, "updated_at": 1704074400000} 4 4 {"text": "Send email to Alice", "completed": false, "created_at": 1704078000000}
+4 -3
tests/test_formatters.py
··· 759 759 assert "~~[cancelled] Cancelled~~" in chunks[2]["markdown"] 760 760 761 761 def test_format_todos_with_time(self): 762 - """Test formatting with time annotation.""" 762 + """Test formatting with legacy time annotation input.""" 763 763 from apps.todos.todo import format_todos 764 764 765 765 entries = [{"text": "Meeting", "time": "14:00", "completed": False}] 766 + context = {"file_path": "/journal/facets/work/todos/20251215.jsonl"} 766 767 767 - chunks, meta = format_todos(entries) 768 + chunks, meta = format_todos(entries, context) 768 769 769 770 assert len(chunks) == 1 770 - assert "Meeting (14:00)" in chunks[0]["markdown"] 771 + assert "Meeting (" in chunks[0]["markdown"] 771 772 772 773 def test_format_todos_header_facet_from_path(self): 773 774 """Test that facet name and day are extracted from file path."""