eraz3r commited on
Commit
e93ef6b
·
verified ·
1 Parent(s): 49a304d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +311 -1029
app.py CHANGED
@@ -1,1048 +1,330 @@
1
  """
2
- app.py - Second Brain Gradio Application
 
3
  """
4
 
5
- import os
6
- import gradio as gr
7
- from datetime import date, datetime
8
-
9
- from core.database import (
10
- init_db, register_user, login_user, get_username,
11
- create_default_life_areas, save_goals, get_goals,
12
- get_life_areas, get_life_area_names, add_life_area, delete_life_area,
13
- get_tasks, save_task, assign_task_date, toggle_task_complete, delete_task,
14
- get_today_stats, load_user_context, save_user_context, spawn_due_habits,
15
- )
16
- from core.ai_engine import (
17
- init_groq, create_blank_context,
18
- parse_task_with_groq, smart_schedule_tasks,
19
- build_opening_question, get_next_journal_question, synthesize_journal,
20
- )
21
- from core.styles import CSS
22
-
23
- init_db()
24
- init_groq()
25
-
26
- # Deadline added to headers
27
- TASK_HEADERS = ["ID", "Done", "Title", "Area", "Urgency", "Importance", "Mind", "Min", "Deadline", "Scheduled"]
28
- _whisper_model = None
29
-
30
-
31
- # =============================================================================
32
- # SHARED HELPERS
33
- # =============================================================================
34
-
35
- def _ok(msg): return f'<span style="color:#4ade80;font-size:13px">\u2713 {msg}</span>'
36
- def _err(msg): return f'<span style="color:#f87171;font-size:13px">\u26a0 {msg}</span>'
37
- def _info(msg): return f'<span style="color:#60a5fa;font-size:13px">\u2139 {msg}</span>'
38
-
39
- def _stat(val, label, color):
40
- return f'<div class="stat-card"><div class="stat-num" style="color:{color}">{val}</div><div class="stat-label">{label}</div></div>'
41
-
42
- def _fmt_tasks(tasks):
43
- rows = []
44
- for t in tasks:
45
- deadline = t.get("deadline_date") or ""
46
- if deadline:
47
- # Highlight imminent deadlines
48
- try:
49
- dl = date.fromisoformat(deadline)
50
- days_left = (dl - date.today()).days
51
- if days_left < 0:
52
- deadline = f"\u274c {deadline}"
53
- elif days_left == 0:
54
- deadline = f"\U0001f6a8 TODAY"
55
- elif days_left == 1:
56
- deadline = f"\u26a0\ufe0f tmrw"
57
- elif days_left <= 3:
58
- deadline = f"\u23f0 {deadline}"
59
- except ValueError:
60
- pass
61
- rows.append([
62
- t["id"],
63
- "\u2705" if t["is_completed"] else "\u2b1c",
64
- ("\U0001f501 " if t["is_habit"] else "") + t["title"],
65
- t["life_area"] or "\u2014",
66
- t["urgency"] or "\u2014",
67
- t["importance"] or "\u2014",
68
- t["state_of_mind"] or "\u2014",
69
- str(t["time_estimate"]) + "m" if t["time_estimate"] else "\u2014",
70
- deadline or "\u2014",
71
- t["scheduled_date"] or "\u2014 unscheduled",
72
- ])
73
- return rows
74
-
75
- def _area_choices(user_id):
76
- return ["All"] + (get_life_area_names(user_id) if user_id else [])
77
-
78
- def _ensure_context(user_id):
79
- ctx = load_user_context(user_id)
80
- if not ctx:
81
- ctx = create_blank_context(user_id)
82
- save_user_context(user_id, ctx)
83
- return ctx
84
-
85
- def _transcribe(audio_path):
86
- global _whisper_model
87
- from faster_whisper import WhisperModel
88
- if _whisper_model is None:
89
- _whisper_model = WhisperModel("small", device="cpu", compute_type="int8")
90
- segments, _ = _whisper_model.transcribe(audio_path)
91
- return " ".join(seg.text for seg in segments).strip()
92
-
93
- def _build_clar_html(clarifications):
94
- if not clarifications:
95
- return ""
96
- items = "".join(f"<li style='margin:4px 0'>{q}</li>" for q in clarifications)
97
- return (
98
- f'<div style="margin:10px 0;padding:12px;background:#0c0f1a;'
99
- f'border-left:3px solid #a78bfa;border-radius:8px">'
100
- f'<div style="color:#a78bfa;font-size:11px;font-weight:600;margin-bottom:6px">\u26a0 Please clarify:</div>'
101
- f'<ul style="color:#94a3b8;font-size:13px;margin:0;padding-left:16px">{items}</ul></div>'
102
- )
103
-
104
- def _run_parse(task_text, user_id):
105
- ctx = _ensure_context(user_id)
106
- goals = get_goals(user_id)
107
- areas = get_life_area_names(user_id) if user_id else []
108
- result = parse_task_with_groq(task_text, ctx, goals, areas)
109
- clars = result.get("clarifications_needed", [])
110
- area_choices = areas or ["Work","Health","Finance","Learning","Personal","Family","Other"]
111
- area_val = result.get("life_area") or area_choices[0]
112
- if area_val not in area_choices:
113
- area_choices = [area_val] + area_choices
114
- return result, _build_clar_html(clars), bool(clars), area_val, clars
115
-
116
- # Parse result -> UI outputs
117
- # Outputs: parsed_panel, f_title, clar_html, f_area, f_urgency, f_importance,
118
- # f_state, f_time, f_deadline, f_is_habit, f_interval, parse_status,
119
- # clar_reply_row, clar_questions_state, original_task_state
120
- # (15 values — date field removed, deadline added)
121
- def _parse_result_to_outputs(result, clar_html, has_clar, area_val, clars, task_text, status=""):
122
- if not status:
123
- status = _ok("AI classified your task \u2014 answer the questions above." if has_clar else "AI classified your task \u2713")
124
- return (
125
- gr.update(visible=True),
126
- result.get("title", task_text),
127
- clar_html, area_val,
128
- result.get("urgency", "Not Urgent"),
129
- result.get("importance", "Important"),
130
- result.get("state_of_mind", "Easy"),
131
- int(result.get("time_estimate") or 30),
132
- "", # deadline_date — left empty for user to fill if needed
133
- False, "Daily",
134
- status,
135
- gr.update(visible=has_clar),
136
- clars, task_text,
137
- )
138
-
139
- _EMPTY_PARSE = (
140
- gr.update(visible=False), "", "", "Work", "Not Urgent", "Important",
141
- "Easy", 30, "", False, "Daily", "",
142
- gr.update(visible=False), [], "",
143
- )
144
-
145
-
146
- # =============================================================================
147
- # AUTH
148
- # =============================================================================
149
-
150
- def handle_login(username, password):
151
- uid, msg = login_user(username, password)
152
- if not uid:
153
- return None, "", _err(msg), gr.update(visible=True), gr.update(visible=False)
154
- spawn_due_habits(uid)
155
- return uid, get_username(uid), _ok(msg), gr.update(visible=False), gr.update(visible=True)
156
-
157
- def handle_register(username, password, wake, sleep, focus, goals_text):
158
- uid, msg = register_user(username, password)
159
- if not uid:
160
- return None, "", _err(msg), gr.update(visible=True), gr.update(visible=False)
161
- create_default_life_areas(uid)
162
- if goals_text.strip():
163
- save_goals(uid, goals_text)
164
- ctx = create_blank_context(uid, {"wake_time": wake or "07:30", "sleep_time": sleep or "23:00", "focus_peak": focus or "Morning"})
165
- save_user_context(uid, ctx)
166
- spawn_due_habits(uid)
167
- return uid, get_username(uid), _ok(msg + " Logged in!"), gr.update(visible=False), gr.update(visible=True)
168
-
169
- def handle_logout(user_id):
170
- return None, "", gr.update(visible=True), gr.update(visible=False)
171
-
172
-
173
- # =============================================================================
174
- # TODAY TAB
175
- # =============================================================================
176
-
177
- def refresh_today(user_id):
178
- if not user_id:
179
- return [], _stat("\u2014","Today","#a78bfa"), _stat("\u2014","Done","#4ade80"), _stat("\u2014","Left","#f87171")
180
- tasks = get_tasks(user_id, only_today=True)
181
- s = get_today_stats(user_id)
182
- c = "#4ade80" if s["remaining"] == 0 and s["total"] > 0 else "#f87171"
183
- return _fmt_tasks(tasks), _stat(s["total"],"Today","#a78bfa"), _stat(s["done"],"Done","#4ade80"), _stat(s["remaining"],"Left",c)
184
-
185
- def refresh_all_tasks_auto(user_id):
186
- """Refresh the All Tasks tab with all tasks regardless of date."""
187
- if not user_id:
188
- return []
189
- return _fmt_tasks(get_tasks(user_id))
190
-
191
- def show_text_panel(): return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False)
192
- def show_voice_panel(): return gr.update(visible=False), gr.update(visible=True), gr.update(visible=False)
193
- def show_plan_panel(): return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True)
194
- def hide_panels(): return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
195
-
196
- # ── Text parse ─────────────────────────────────────────────────────────────────
197
-
198
- def handle_parse_text(task_text, user_id):
199
- if not task_text.strip():
200
- return _EMPTY_PARSE
201
- return _parse_result_to_outputs(*_run_parse(task_text, user_id), task_text)
202
-
203
- def handle_clarification_reply(user_reply, original_task, clarifications, user_id):
204
- if not user_reply.strip():
205
- return (gr.update(),)*8 + (gr.update(visible=True), [])
206
- q_block = "\n".join(f"Q: {q}" for q in clarifications)
207
- enriched = f"{original_task}\n\nUser clarification:\n{q_block}\nA: {user_reply}"
208
- result, clar_html_val, still_has, area_val, remaining = _run_parse(enriched, user_id)
209
- if not still_has:
210
- clar_html_val = '<span style="color:#4ade80;font-size:13px">\u2713 Classification updated!</span>'
211
- return (
212
- result.get("title", original_task), clar_html_val, area_val,
213
- result.get("urgency","Not Urgent"), result.get("importance","Important"),
214
- result.get("state_of_mind","Easy"), int(result.get("time_estimate") or 30),
215
- "", # deadline stays empty
216
- gr.update(visible=still_has), remaining,
217
- )
218
-
219
- def handle_clar_voice(audio_path, original_task, clarifications, user_id):
220
- if not audio_path:
221
- return (gr.update(),)*8 + (gr.update(visible=True), [])
222
  try:
223
- reply = _transcribe(audio_path)
224
- return handle_clarification_reply(reply, original_task, clarifications, user_id)
225
  except Exception:
226
- return (gr.update(),)*8 + (gr.update(visible=True), clarifications)
227
 
228
- # ── Voice parse ────────────────────────────────────────────────────────────────
 
229
 
230
- def handle_voice_parse(audio_path, user_id):
231
- if audio_path is None:
232
- return _EMPTY_PARSE[:-2] + (_err("No audio recorded."), gr.update(visible=False), [], "")
233
- try:
234
- text = _transcribe(audio_path)
235
- except Exception as e:
236
- return _EMPTY_PARSE[:-2] + (_err(f"Transcription error: {e}"), gr.update(visible=False), [], "")
237
- if not text:
238
- return _EMPTY_PARSE[:-2] + (_err("Could not hear anything. Try again."), gr.update(visible=False), [], "")
239
- result, clar_html, has_clar, area_val, clars = _run_parse(text, user_id)
240
- preview = text[:50] + ("\u2026" if len(text) > 50 else "")
241
- status = _ok(f'Heard: "{preview}" \u2014 classified \u2713' + (" Please answer questions." if has_clar else ""))
242
- return _parse_result_to_outputs(result, clar_html, has_clar, area_val, clars, text, status)
243
 
244
- # ── Confirm task — saves WITHOUT a scheduled date ──────────────────────────────
 
245
 
246
- def handle_confirm_task(user_id, title, area, urgency, importance, state,
247
- time_est, deadline, is_habit, habit_interval):
248
- if not user_id:
249
- return _err("Not logged in."), [], _stat("\u2014","Today","#a78bfa"), _stat("\u2014","Done","#4ade80"), _stat("\u2014","Left","#f87171"), [], gr.update(visible=False)
250
- if not title.strip():
251
- return _err("Title cannot be empty."), [], _stat("\u2014","Today","#a78bfa"), _stat("\u2014","Done","#4ade80"), _stat("\u2014","Left","#f87171"), [], gr.update(visible=True)
252
- save_task(user_id, {
253
- "title": title, "life_area": area, "urgency": urgency, "importance": importance,
254
- "state_of_mind": state, "time_estimate": int(time_est or 30),
255
- "deadline_date": deadline.strip() if deadline else "",
256
- "is_habit": is_habit, "habit_interval": habit_interval if is_habit else "",
257
- }, scheduled_date="") # <- no date assigned; scheduler does this
258
- rows, s1, s2, s3 = refresh_today(user_id)
259
- all_rows = refresh_all_tasks_auto(user_id)
260
- return _ok("Task saved! Use \u2018Plan My Tasks\u2019 to schedule it."), rows, s1, s2, s3, all_rows, gr.update(visible=False)
261
 
 
262
 
263
- # =============================================================================
264
- # SMART TASK SCHEDULER (Plan My Tasks)
265
- # =============================================================================
266
-
267
- def handle_smart_schedule(user_id, prompt):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  """
269
- RAG-style scheduler: reads all unscheduled tasks + user context/goals/patterns,
270
- then assigns each task to the best future date based on the user's request.
271
- Updates the DB and shows results. Never touches past dates.
272
  """
273
- if not user_id:
274
- return _err("Not logged in."), ""
275
-
276
- # Get ALL unscheduled tasks (across all dates)
277
- unscheduled = get_tasks(user_id, only_unscheduled=True, include_completed=False)
278
- if not unscheduled:
279
- # Also check if user wants to reschedule today or future tasks
280
- all_tasks = get_tasks(user_id, include_completed=False)
281
- if not all_tasks:
282
- return _err("You have no tasks yet. Add some tasks first."), ""
283
- # If everything is already scheduled, offer to reschedule
284
- unscheduled = all_tasks
285
- prompt = prompt + " (Note: all tasks already have dates; feel free to reassign them)"
286
-
287
- ctx = _ensure_context(user_id)
288
- goals = get_goals(user_id)
289
- now = datetime.now()
290
-
291
- result = smart_schedule_tasks(
292
- tasks = unscheduled,
293
- user_context = ctx,
294
- user_goals = goals,
295
- scheduling_prompt= prompt or "Schedule my tasks intelligently across the next week.",
296
- current_dt = now,
297
- )
298
-
299
- # Apply assignments to DB
300
- assigned_count = 0
301
- for a in result.get("assignments", []):
302
- try:
303
- task_id = int(a["task_id"])
304
- assigned_date = a["assigned_date"]
305
- assign_task_date(task_id, user_id, assigned_date)
306
- assigned_count += 1
307
- except (ValueError, KeyError, TypeError):
308
- pass
309
-
310
- # Build result HTML
311
- COLOR = {"Flow": "#0ea5e9", "Easy": "#4ade80", "Quick": "#a78bfa", "Personal": "#f87171",
312
- "Urgent": "#f87171", "Not Urgent": "#4ade80", "Habit": "#a78bfa"}
313
-
314
- # Summary header
315
- html = (
316
- f'<div style="margin-bottom:14px;padding:12px;background:#0c0f1a;border-radius:10px;">'
317
- f'<div style="color:#a78bfa;font-size:12px;font-weight:600;margin-bottom:6px">'
318
- f'\U0001f9e0 AI Scheduling Complete \u2014 {assigned_count} task(s) assigned</div>'
319
- f'<p style="color:#94a3b8;font-size:13px;margin:0">{result.get("summary", "")}</p>'
320
- f'</div>'
321
- )
322
-
323
- # Assignments grouped by date
324
- by_date = {}
325
- assignments = result.get("assignments", [])
326
- # Build a quick lookup from task_id to task data
327
- task_lookup = {t["id"]: t for t in unscheduled}
328
-
329
- for a in assignments:
330
- d = a.get("assigned_date", "?")
331
- by_date.setdefault(d, []).append(a)
332
-
333
- for d in sorted(by_date.keys()):
334
- try:
335
- dl = date.fromisoformat(d)
336
- days_from_now = (dl - date.today()).days
337
- if days_from_now == 0: label = f"{d} (Today)"
338
- elif days_from_now == 1: label = f"{d} (Tomorrow)"
339
- else: label = f"{d} ({dl.strftime('%A')})"
340
- except ValueError:
341
- label = d
342
-
343
- html += f'<div style="margin:10px 0 4px;color:#60a5fa;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px">\U0001f4c5 {label}</div>'
344
- for a in by_date[d]:
345
- tid = a.get("task_id")
346
- task = task_lookup.get(int(tid) if tid else -1, {})
347
- sm = task.get("state_of_mind","Easy")
348
- c = COLOR.get(sm, "#6366f1")
349
- deadline_str = ""
350
- if task.get("deadline_date"):
351
- deadline_str = f' | \\u23f3 deadline {task["deadline_date"]}'
352
-
353
- emdash = "—" # or "\u2014"
354
- life_area = task.get("life_area", emdash)
355
- html += (
356
- f'<div class="sched-card" style="border-left-color:{c};margin-bottom:6px">'
357
- f'<div class="sched-title">{a.get("title","")}</div>'
358
- f'<div class="sched-meta">{life_area} · {sm} · {task.get("time_estimate","?")}min{deadline_str}</div>'
359
- f'<div class="sched-why">💡 {a.get("reasoning","")}</div>'
360
- f'</div>'
361
- )
362
-
363
- # Skipped tasks
364
- skipped = result.get("skipped", [])
365
- if skipped:
366
- html += '<div style="margin-top:12px;color:#334155;font-size:11px;font-weight:600;text-transform:uppercase">Not scheduled</div>'
367
- for s in skipped:
368
- html += f'<div style="color:#475569;font-size:12px;padding:3px 0">\u2717 {s.get("title","")} \u2014 {s.get("reason","")}</div>'
369
-
370
- # All Tasks board (update after scheduling)
371
- all_rows = refresh_all_tasks_auto(user_id)
372
- return html, all_rows
373
-
374
- def handle_toggle_today(task_id, user_id):
375
- if task_id and user_id: toggle_task_complete(int(task_id), user_id)
376
- rows, s1, s2, s3 = refresh_today(user_id)
377
- return rows, s1, s2, s3, _ok("Updated")
378
-
379
- def handle_delete_today(task_id, user_id):
380
- if task_id and user_id: delete_task(int(task_id), user_id)
381
- rows, s1, s2, s3 = refresh_today(user_id)
382
- return rows, s1, s2, s3, _ok("Deleted")
383
-
384
-
385
- # =============================================================================
386
- # ALL TASKS TAB
387
- # =============================================================================
388
-
389
- def refresh_all_tasks(user_id, filter_area="All", only_today=False):
390
- if not user_id: return []
391
- return _fmt_tasks(get_tasks(user_id, filter_area=filter_area, only_today=only_today))
392
-
393
- def handle_toggle_all(task_id, user_id, filter_area, only_today):
394
- if task_id and user_id: toggle_task_complete(int(task_id), user_id)
395
- return refresh_all_tasks(user_id, filter_area, only_today), _ok("Updated")
396
-
397
- def handle_delete_all(task_id, user_id, filter_area, only_today):
398
- if task_id and user_id: delete_task(int(task_id), user_id)
399
- return refresh_all_tasks(user_id, filter_area, only_today), _ok("Deleted")
400
-
401
-
402
- # =============================================================================
403
- # JOURNAL
404
- # =============================================================================
405
-
406
- def _task_list_md(tasks):
407
- if not tasks: return "*No tasks found.*"
408
- lines = []
409
- for t in tasks:
410
- tid = t.get("task_id") or t.get("id")
411
- status = "\u2705" if (t.get("is_completed") or t.get("completed")) else "\u2b1c"
412
- dur = f" \u00b7 {t['actual_duration']}m actual" if t.get("actual_duration") else ""
413
- lines.append(f"{status} **#{tid}** {t['title']} _{t.get('life_area','')} \u00b7 {t.get('time_estimate','')}m{dur}_")
414
- return "\n".join(lines)
415
-
416
- def _parse_ids(text, valid_ids):
417
- import re as _re
418
- return [int(n) for n in _re.findall(r'\b(\d+)\b', text) if int(n) in valid_ids]
419
-
420
- def handle_start_journal(user_id):
421
- blank = ([], [], False, gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), [], "marking", "")
422
- if not user_id:
423
- return ([(None, "\u26a0 Please sign in first.")],) + blank[1:]
424
- tasks = get_tasks(user_id, only_today=True)
425
- if not tasks:
426
- msg = "No tasks logged for today. Add and schedule some tasks first, then come back to reflect!"
427
- return ([(None, msg)],) + blank[1:]
428
-
429
- tasks_state = [{
430
- "task_id": t["id"], "title": t["title"], "life_area": t.get("life_area",""),
431
- "state_of_mind": t.get("state_of_mind",""), "time_estimate": t.get("time_estimate",30),
432
- "completed": bool(t["is_completed"]), "actual_duration": t.get("actual_duration"),
433
- } for t in tasks]
434
-
435
- total = len(tasks_state)
436
- completed = sum(1 for t in tasks_state if t["completed"])
437
- task_md = _task_list_md(tasks_state)
438
-
439
- if completed == total and total > 0:
440
- opener = f"\U0001f389 You knocked out all **{total}** tasks today \u2014 great work!\n\n{task_md}\n\nDid the day feel productive or were you grinding through it?"
441
- elif completed == 0:
442
- opener = (f"Here\'s what was on your plate today:\n\n{task_md}\n\n"
443
- f"None are marked complete yet. Tell me which ones you finished "
444
- f"(e.g. *\"I did #1 and #3\"*), or say **skip** to start reflecting.")
445
- else:
446
- incomplete = [t for t in tasks_state if not t["completed"]]
447
- inc_str = ", ".join(f"#{t['task_id']}" for t in incomplete[:4])
448
- opener = (f"Here\'s today\'s list:\n\n{task_md}\n\n"
449
- f"Done: **{completed}/{total}**. Still open: {inc_str}. "
450
- f"Tell me if you finished any more, or say **skip** to reflect.")
451
-
452
- first_msg = (None, opener)
453
- hist = [{"role": "assistant", "content": opener, "focus": "task_review"}]
454
- return (
455
- [first_msg], hist, True,
456
- gr.update(interactive=True), gr.update(interactive=True), gr.update(interactive=True),
457
- tasks_state, "marking", "",
458
  )
459
-
460
-
461
- def handle_journal_message(user_id, user_text, audio_path, chat, hist, tasks_state, active, phase):
462
- voice_used = False
463
- if audio_path is not None:
464
- try:
465
- transcribed = _transcribe(audio_path)
466
- if transcribed:
467
- user_text = transcribed
468
- voice_used = True
469
- except Exception as e:
470
- err = (None, f"\u26a0 Couldn\'t transcribe audio: {e}. Please type instead.")
471
- return list(chat) + [err], hist, "", None, tasks_state, active, phase, ""
472
-
473
- if not active or not user_text.strip():
474
- return chat, hist, "", None, tasks_state, active, phase, ""
475
-
476
- display_text = f"\U0001f3a4 *{user_text}*" if voice_used else user_text
477
- chat = list(chat) + [(display_text, None)]
478
- hist = list(hist) + [{"role": "user", "content": user_text}]
479
-
480
- # Phase: marking tasks
481
- if phase == "marking":
482
- valid_ids = {t["task_id"] for t in tasks_state}
483
- mentioned = _parse_ids(user_text, valid_ids)
484
- updated = []
485
- for t in tasks_state:
486
- if t["task_id"] in mentioned and not t["completed"]:
487
- toggle_task_complete(t["task_id"], user_id)
488
- t["completed"] = True
489
- updated.append(t["title"])
490
-
491
- titles_str = ", ".join(updated)
492
- confirm = (f"Got it \u2014 marked **{titles_str}** as complete \u2705\n\n" if updated else "")
493
- skip_words = {"done","skip","reflect","none","nothing","no more","that\'s it","let\'s go","start","go","next","continue"}
494
- wants_skip = any(w in user_text.lower() for w in skip_words)
495
- done_count = sum(1 for t in tasks_state if t["completed"])
496
-
497
- if wants_skip or done_count == len(tasks_state) or (updated and done_count > 0):
498
- ctx = _ensure_context(user_id)
499
- opening = build_opening_question(ctx, tasks_state)
500
- bridge = confirm + opening["question"]
501
- ai_msg = (None, bridge)
502
- hist.append({"role": "assistant", "content": bridge, "focus": opening["question_focus"]})
503
- return list(chat) + [ai_msg], hist, "", None, tasks_state, active, "reflecting", ""
504
  else:
505
- remaining = [t for t in tasks_state if not t["completed"]]
506
- rem_str = " | ".join(f"#{t['task_id']} {t['title']}" for t in remaining[:5])
507
- follow = confirm + f"Still pending: {rem_str}\n\nAnything else done? Or say **skip** to start reflecting."
508
- ai_msg = (None, follow)
509
- hist.append({"role": "assistant", "content": follow, "focus": "task_marking"})
510
- return list(chat) + [ai_msg], hist, "", None, tasks_state, active, "marking", ""
511
-
512
- # Phase: reflecting
513
- if phase == "reflecting":
514
- try:
515
- ctx = _ensure_context(user_id)
516
- result = get_next_journal_question(ctx, tasks_state, hist)
517
- except Exception as e:
518
- err_msg = (None, f"\u26a0 AI error: {e}. Try sending again.")
519
- return list(chat) + [err_msg], hist, "", None, tasks_state, active, phase, ""
520
-
521
- if result.get("session_complete"):
522
- closing = "That\'s a solid reflection \u2014 I have plenty to work with. \U0001f9e0 Hit **Finish & Save** to lock in your insights!"
523
- ai_msg = (None, closing)
524
- hist.append({"role": "assistant", "content": closing, "focus": "complete"})
525
- return list(chat) + [ai_msg], hist, "", None, tasks_state, active, "done", _ok("Session complete \u2014 click Finish & Save")
526
 
527
- next_q = result.get("question") or "Anything else you\'d like to reflect on?"
528
- ai_msg = (None, next_q)
529
- hist.append({"role": "assistant", "content": next_q, "focus": result.get("question_focus","")})
530
- return list(chat) + [ai_msg], hist, "", None, tasks_state, active, "reflecting", ""
531
 
532
- # Phase: done
533
- nudge = (None, "Session complete! Click **Finish & Save** to save your insights.")
534
- return list(chat) + [nudge], hist, "", None, tasks_state, active, "done", ""
 
535
 
536
 
537
- def handle_finish_journal(user_id, chat, hist, tasks_state):
538
- if not user_id or not hist:
539
- return list(chat) + [(None, "\u26a0 Nothing to save yet.")], ""
540
- try:
541
- ctx = _ensure_context(user_id)
542
- updated = synthesize_journal(ctx, tasks_state, hist)
543
- save_user_context(user_id, updated)
544
- except Exception as e:
545
- return list(chat) + [(None, f"\u26a0 Save failed: {e}")], _err("Save failed")
546
-
547
- total = len(tasks_state)
548
- done = sum(1 for t in tasks_state if t.get("completed"))
549
- rate = round(done / total * 100) if total else 0
550
- notes = updated.get("learned_patterns",{}).get("notes",[])
551
- notes_md = "\n".join(f"\u2022 {n}" for n in notes[-3:]) if notes else "\u2022 Keep reflecting to build patterns"
552
- summary = (
553
- f"\u2705 **Insights saved!** {done}/{total} tasks ({rate}%) complete today.\n\n"
554
- f"**What I learned:**\n{notes_md}\n\n"
555
- f"I\'ll use this to make tomorrow\'s scheduling smarter. Great work today! \U0001f319"
556
- )
557
- return list(chat) + [(None, summary)], _ok(f"Saved! {done}/{total} tasks ({rate}%)")
558
-
559
-
560
- def handle_restart_journal():
561
- return [], [], False, gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), [], "marking", ""
562
-
563
-
564
- # =============================================================================
565
- # LIFE AREAS & GOALS
566
- # =============================================================================
567
-
568
- def render_areas(user_id):
569
- if not user_id: return "", [], []
570
- areas = get_life_areas(user_id)
571
- chips = "".join(
572
- f'<span class="chip" style="background:{a["color"]}22;color:{a["color"]};border:1px solid {a["color"]}44">{a["name"]}</span> '
573
- for a in areas
574
- )
575
- html = f'<div style="margin:4px 0">{chips}</div>' if chips else '<p style="color:#334155;font-size:13px">No areas yet.</p>'
576
- names = [a["name"] for a in areas]
577
- return html, names, names
578
-
579
- def handle_add_area(user_id, name, color):
580
- ok, msg = add_life_area(user_id, name, color)
581
- html, names, _ = render_areas(user_id)
582
- ct = "#4ade80" if ok else "#f87171"
583
- return f'<span style="color:{ct};font-size:13px">{msg}</span>', html, gr.update(choices=names, value=None), gr.update(value="")
584
-
585
- def handle_del_area(user_id, name):
586
- if not name: return _err("Select an area first."), "", []
587
- ok, msg = delete_life_area(user_id, name)
588
- html, names, _ = render_areas(user_id)
589
- ct = "#4ade80" if ok else "#f87171"
590
- return f'<span style="color:{ct};font-size:13px">{msg}</span>', html, gr.update(choices=names, value=None)
591
-
592
- def handle_save_goals(user_id, goals_text):
593
- if not user_id: return _err("Not logged in.")
594
- save_goals(user_id, goals_text)
595
- return _ok("Goals saved!")
596
-
597
- def load_goals_txt(user_id):
598
- return "\n".join(get_goals(user_id)) if user_id else ""
599
-
600
-
601
- # =============================================================================
602
- # PREFERENCES
603
- # =============================================================================
604
-
605
- def load_prefs(user_id):
606
- ctx = load_user_context(user_id) if user_id else None
607
- if not ctx: return "07:30","23:00","Morning",10,90
608
- p = ctx.get("preferences",{})
609
- return p.get("wake_time","07:30"), p.get("sleep_time","23:00"), p.get("focus_peak","Morning"), p.get("break_duration_minutes",10), p.get("max_flow_block_minutes",90)
610
-
611
- def handle_save_prefs(user_id, wake, sleep, focus, brk, flow_max):
612
- if not user_id: return _err("Not logged in.")
613
- ctx = _ensure_context(user_id)
614
- ctx["preferences"].update({"wake_time":wake or "07:30","sleep_time":sleep or "23:00","focus_peak":focus or "Morning","break_duration_minutes":int(brk or 10),"max_flow_block_minutes":int(flow_max or 90)})
615
- save_user_context(user_id, ctx)
616
- return _ok("Preferences saved!")
617
-
618
- def render_context_html(user_id):
619
- if not user_id: return "<p style='color:#334155'>Not logged in.</p>"
620
- ctx = load_user_context(user_id)
621
- if not ctx: return "<p style='color:#334155;font-size:13px'>No AI context yet. Complete a journal session to build it.</p>"
622
- lp = ctx.get("learned_patterns",{}); sf = ctx.get("scheduling_feedback",{}); pref = ctx.get("preferences",{})
623
- def _row(l,v): return f'<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid #1e293b"><span style="color:#475569;font-size:12px">{l}</span><span style="color:#cbd5e1;font-size:12px">{v}</span></div>'
624
- def _hdr(t): return f'<div style="color:#a78bfa;font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;margin:14px 0 8px">{t}</div>'
625
- html = "<div style='font-size:13px'>" + _hdr("Preferences")
626
- html += _row("Wake",pref.get("wake_time","\u2014"))+_row("Sleep",pref.get("sleep_time","\u2014"))+_row("Peak Focus",pref.get("focus_peak","\u2014"))
627
- html += _hdr("Learned Patterns")
628
- html += _row("Avg overrun",f'{lp.get("avg_task_overrun_pct",0)}%')+_row("Flow batching",str(lp.get("flow_batch_capable","Unknown")))
629
- html += _row("Days tracked",str(sf.get("total_days_scheduled",0)))+_row("Avg completion",f'{round(sf.get("avg_completion_rate",0)*100)}%')
630
- notes = lp.get("notes",[])
631
- if notes:
632
- html += _hdr("AI Notes")
633
- html += "".join(f'<div style="color:#94a3b8;font-size:12px;padding:3px 0">\u2022 {n}</div>' for n in notes[-5:])
634
- html += f'<div style="color:#334155;font-size:11px;margin-top:12px">v{ctx.get("version",1)} \u00b7 {ctx.get("last_updated","\u2014")[:10]}</div></div>'
635
- return html
636
-
637
-
638
- # =============================================================================
639
- # UI
640
- # =============================================================================
641
-
642
- with gr.Blocks(title="\U0001f9e0 Second Brain") as demo:
643
-
644
- user_id_state = gr.State(None)
645
- username_state = gr.State("")
646
- # Journal
647
- journal_hist_state = gr.State([])
648
- journal_tasks_state = gr.State([])
649
- journal_active = gr.State(False)
650
- journal_phase_state = gr.State("marking")
651
- # Clarification — text panel
652
- clar_questions_state = gr.State([])
653
- original_task_state = gr.State("")
654
- # Clarification — voice panel
655
- vclar_questions_state = gr.State([])
656
- voriginal_task_state = gr.State("")
657
-
658
- # AUTH
659
- with gr.Column(visible=True, elem_id="auth-card") as auth_section:
660
- gr.HTML('<div id="brand-logo">\U0001f9e0 Second Brain</div>')
661
- gr.HTML('<div id="brand-sub">Your intelligent productivity companion</div>')
662
- with gr.Tabs():
663
- with gr.Tab("Sign In"):
664
- login_user_in = gr.Textbox(label="Username", placeholder="your username")
665
- login_pass_in = gr.Textbox(label="Password", type="password", placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")
666
- login_btn = gr.Button("Sign In", elem_classes="btn-primary")
667
- login_msg = gr.HTML("")
668
- with gr.Tab("Create Account"):
669
- reg_user_in = gr.Textbox(label="Username", placeholder="choose a username")
670
- reg_pass_in = gr.Textbox(label="Password (min 6 chars)", type="password")
671
- reg_wake = gr.Textbox(label="Wake time", value="07:30")
672
- reg_sleep = gr.Textbox(label="Sleep time", value="23:00")
673
- reg_focus = gr.Dropdown(label="Peak focus", choices=["Morning","Afternoon","Evening","Night"], value="Morning")
674
- reg_goals = gr.Textbox(label="Big-picture goals (optional)", lines=3, placeholder="Launch my startup\nGet fit\nLearn ML")
675
- reg_btn = gr.Button("Create Account", elem_classes="btn-primary")
676
- reg_msg = gr.HTML("")
677
-
678
- # MAIN APP
679
- with gr.Column(visible=False) as app_section:
680
-
681
- with gr.Row(elem_id="top-header"):
682
- header_html = gr.HTML('<div id="top-header-brand">\U0001f9e0 Second Brain</div><div id="top-header-user">\u2014</div>')
683
- logout_btn = gr.Button("Sign Out", elem_classes="btn-secondary", scale=0)
684
-
685
- with gr.Tabs():
686
-
687
- # TAB 1: TODAY
688
- with gr.Tab("\U0001f4c5 Today"):
689
- with gr.Row():
690
- stat_total = gr.HTML(_stat("\u2014","Today","#a78bfa"))
691
- stat_done = gr.HTML(_stat("\u2014","Done","#4ade80"))
692
- stat_remain = gr.HTML(_stat("\u2014","Left","#f87171"))
693
- with gr.Row():
694
- btn_add_text = gr.Button("\u270f\ufe0f Add Task (Text)", elem_classes="btn-primary", scale=3)
695
- btn_add_voice = gr.Button("\U0001f399\ufe0f Add Task (Voice)", elem_classes="btn-accent", scale=3)
696
- btn_plan_day = gr.Button("\U0001f9e0 Plan My Tasks", elem_classes="btn-secondary", scale=3)
697
- btn_refresh = gr.Button("\u21bb", elem_classes="btn-secondary", scale=1)
698
-
699
- # Text panel
700
- with gr.Column(visible=False, elem_classes="panel") as text_panel:
701
- gr.HTML('<div class="sec-label">Describe Your Task</div>')
702
- task_input = gr.Textbox(label="", placeholder="e.g. Finish the client proposal, it\'s really important", lines=2)
703
- with gr.Row():
704
- btn_parse = gr.Button("\U0001f916 Parse with AI", elem_classes="btn-primary")
705
- btn_cancel_text = gr.Button("Cancel", elem_classes="btn-secondary")
706
-
707
- with gr.Column(visible=False) as parsed_panel:
708
- parse_status = gr.HTML("")
709
- clar_html = gr.HTML("")
710
- with gr.Column(visible=False) as clar_reply_row:
711
- gr.HTML('<div class="sec-label" style="margin-top:8px">Answer the questions above</div>')
712
- with gr.Row():
713
- clar_text_input = gr.Textbox(label="Type your answer", lines=2, scale=4, placeholder="e.g. It\'s urgent, about 2 hours, for my manager")
714
- clar_audio_input = gr.Audio(sources=["microphone"], type="filepath", label="\U0001f3a4 Or speak", scale=1)
715
- btn_clar_submit = gr.Button("\U0001f504 Re-classify", elem_classes="btn-accent")
716
- gr.HTML('<div class="sec-label" style="margin-top:12px">Review & Confirm</div>')
717
- gr.HTML('<p style="color:#475569;font-size:12px;margin:0 0 10px">Date will be assigned by the AI scheduler \u2014 just save and use \u201cPlan My Tasks\u201d.</p>')
718
- with gr.Row():
719
- f_title = gr.Textbox(label="Title", scale=3)
720
- f_area = gr.Dropdown(label="Life Area", choices=["Work","Health","Finance","Learning","Personal","Family","Other"], scale=1)
721
- with gr.Row():
722
- f_urgency = gr.Dropdown(label="Urgency", choices=["Urgent","Not Urgent","Habit"], value="Not Urgent")
723
- f_importance = gr.Dropdown(label="Importance", choices=["Move the Needle","Important","Not Important"], value="Important")
724
- f_state = gr.Dropdown(label="State of Mind", choices=["Flow","Easy","Quick","Personal"], value="Easy")
725
- with gr.Row():
726
- f_time = gr.Number(label="Minutes", value=30, minimum=5, scale=1)
727
- f_deadline = gr.Textbox(label="\U0001f3af Deadline (YYYY-MM-DD, optional)", placeholder="2026-03-01", scale=2)
728
- f_is_habit = gr.Checkbox(label="\u267b\ufe0f Habit", scale=1)
729
- f_interval = gr.Dropdown(label="Recurs", choices=["Daily","Weekly","Weekdays","Weekends","Monthly"], value="Daily", visible=False, scale=1)
730
- with gr.Row():
731
- btn_confirm = gr.Button("\u2713 Save Task", elem_classes="btn-success")
732
- btn_discard = gr.Button("\u2717 Discard", elem_classes="btn-danger")
733
- save_msg = gr.HTML("")
734
-
735
- # Voice panel
736
- with gr.Column(visible=False, elem_classes="panel") as voice_panel:
737
- gr.HTML('<div class="sec-label">Add Task by Voice</div>')
738
- gr.HTML('<p style="color:#475569;font-size:13px;margin-bottom:12px">Record your task \u2014 Whisper transcribes it, AI classifies it. Same flow as text.</p>')
739
- voice_audio_input = gr.Audio(sources=["microphone","upload"], type="filepath", label="")
740
- with gr.Row():
741
- btn_voice_parse = gr.Button("\U0001f916 Transcribe & Classify", elem_classes="btn-primary")
742
- btn_cancel_voice = gr.Button("Cancel", elem_classes="btn-secondary")
743
- voice_status = gr.HTML("")
744
-
745
- with gr.Column(visible=False) as voice_parsed_panel:
746
- voice_transcribed_txt = gr.Textbox(label="Transcribed text (editable)", lines=1)
747
- voice_clar_html = gr.HTML("")
748
- with gr.Column(visible=False) as voice_clar_row:
749
- gr.HTML('<div class="sec-label" style="margin-top:8px">Answer the questions above</div>')
750
- with gr.Row():
751
- vclar_text = gr.Textbox(label="Type your answer", lines=2, scale=4)
752
- vclar_audio = gr.Audio(sources=["microphone"], type="filepath", label="\U0001f3a4 Or speak", scale=1)
753
- btn_vclar_submit = gr.Button("\U0001f504 Re-classify", elem_classes="btn-accent")
754
- gr.HTML('<div class="sec-label" style="margin-top:12px">Review & Confirm</div>')
755
- gr.HTML('<p style="color:#475569;font-size:12px;margin:0 0 10px">Date assigned by AI scheduler after saving.</p>')
756
- with gr.Row():
757
- vf_title = gr.Textbox(label="Title", scale=3)
758
- vf_area = gr.Dropdown(label="Life Area", choices=["Work","Health","Finance","Learning","Personal","Family","Other"], scale=1)
759
- with gr.Row():
760
- vf_urgency = gr.Dropdown(label="Urgency", choices=["Urgent","Not Urgent","Habit"], value="Not Urgent")
761
- vf_importance = gr.Dropdown(label="Importance", choices=["Move the Needle","Important","Not Important"], value="Important")
762
- vf_state = gr.Dropdown(label="State of Mind", choices=["Flow","Easy","Quick","Personal"], value="Easy")
763
- with gr.Row():
764
- vf_time = gr.Number(label="Minutes", value=30, minimum=5, scale=1)
765
- vf_deadline = gr.Textbox(label="\U0001f3af Deadline (optional)", placeholder="2026-03-01", scale=2)
766
- vf_is_habit = gr.Checkbox(label="\u267b\ufe0f Habit", scale=1)
767
- vf_interval = gr.Dropdown(label="Recurs", choices=["Daily","Weekly","Weekdays","Weekends","Monthly"], value="Daily", visible=False, scale=1)
768
- with gr.Row():
769
- btn_voice_confirm = gr.Button("\u2713 Save Task", elem_classes="btn-success")
770
- btn_voice_discard = gr.Button("\u2717 Discard", elem_classes="btn-danger")
771
- voice_save_msg = gr.HTML("")
772
-
773
- # Plan My Tasks panel
774
- with gr.Column(visible=False, elem_classes="panel") as plan_panel:
775
- gr.HTML('<div class="sec-label">Plan My Tasks</div>')
776
- gr.HTML('<p style="color:#475569;font-size:13px;margin-bottom:12px">'
777
- 'The AI reads your unscheduled tasks, your learned patterns, goals, and current time \u2014 '
778
- 'then assigns each task to the best future date. It never places tasks in the past.'
779
- '</p>')
780
- plan_prompt = gr.Textbox(
781
- label="Tell the AI what you want",
782
- placeholder="e.g. \'Clear today, schedule everything from tomorrow\' or \'Focus on Work tasks this week\' or \'I have a free morning Thursday\'",
783
- lines=3
784
- )
785
- with gr.Row():
786
- btn_gen_sched = gr.Button("\U0001f9e0 Run AI Scheduler", elem_classes="btn-primary")
787
- btn_cancel_plan = gr.Button("Cancel", elem_classes="btn-secondary")
788
- schedule_html = gr.HTML("")
789
-
790
- gr.HTML('<div class="sec-label" style="margin-top:20px">Today\'s Tasks</div>')
791
- today_df = gr.Dataframe(headers=TASK_HEADERS, interactive=False, wrap=True)
792
- with gr.Row():
793
- today_done_id = gr.Number(label="Task ID \u2014 toggle done", precision=0, scale=2)
794
- btn_today_done = gr.Button("\u2705 Mark Done", elem_classes="btn-success", scale=2)
795
- today_del_id = gr.Number(label="Task ID \u2014 delete", precision=0, scale=2)
796
- btn_today_del = gr.Button("\U0001f5d1 Delete", elem_classes="btn-danger", scale=2)
797
- today_action_msg = gr.HTML("")
798
-
799
- # TAB 2: ALL TASKS
800
- with gr.Tab("\U0001f4cb All Tasks"):
801
- gr.HTML('<p style="color:#475569;font-size:13px;margin-bottom:10px">All tasks including unscheduled ones. Filter and manage here.</p>')
802
- with gr.Row():
803
- all_filter = gr.Dropdown(label="Filter by Area", choices=["All"], value="All", scale=3)
804
- all_today_chk = gr.Checkbox(label="Today only", value=False, scale=1)
805
- all_unsched = gr.Checkbox(label="Unscheduled only", value=False, scale=1)
806
- btn_all_ref = gr.Button("\u21bb Refresh", elem_classes="btn-secondary", scale=1)
807
- all_df = gr.Dataframe(headers=TASK_HEADERS, interactive=False, wrap=True)
808
- with gr.Row():
809
- all_done_id = gr.Number(label="Task ID \u2014 toggle done", precision=0, scale=2)
810
- btn_all_done = gr.Button("\u2705 Done", elem_classes="btn-success", scale=2)
811
- all_del_id = gr.Number(label="Task ID \u2014 delete", precision=0, scale=2)
812
- btn_all_del = gr.Button("\U0001f5d1 Delete", elem_classes="btn-danger", scale=2)
813
- all_action_msg = gr.HTML("")
814
-
815
- # TAB 3: JOURNAL
816
- with gr.Tab("\U0001f4d3 Journal"):
817
- gr.HTML('<p style="color:#475569;font-size:13px;margin-bottom:12px">End-of-day reflection. Chat naturally \u2014 AI marks tasks, asks questions, updates your profile.</p>')
818
- journal_chatbot = gr.Chatbot(label="", height=440)
819
- with gr.Row():
820
- j_answer = gr.Textbox(label="", placeholder="Type or use the mic\u2026", lines=1, scale=5, interactive=False, show_label=False)
821
- j_audio_in = gr.Audio(sources=["microphone"], type="filepath", label="\U0001f3a4", scale=1, interactive=False)
822
- btn_j_send = gr.Button("Send \u2192", elem_classes="btn-accent", scale=1, interactive=False)
823
- with gr.Row():
824
- btn_j_start = gr.Button("\u25b6 Start Session", elem_classes="btn-primary", scale=3)
825
- btn_j_finish = gr.Button("\u2713 Finish & Save", elem_classes="btn-success", scale=3)
826
- btn_j_restart = gr.Button("\u21ba Reset", elem_classes="btn-secondary", scale=1)
827
- journal_msg = gr.HTML("")
828
-
829
- # TAB 4: AREAS & GOALS
830
- with gr.Tab("\U0001f5c2 Areas & Goals"):
831
- gr.HTML('<div class="sec-label">Your Life Areas</div>')
832
- areas_display = gr.HTML("")
833
- with gr.Row():
834
- new_area_name = gr.Textbox(label="New area name", placeholder="e.g. Side Project", scale=3)
835
- new_area_color = gr.ColorPicker(label="Color", value="#6366f1", scale=1)
836
- btn_add_area = gr.Button("Add", elem_classes="btn-primary", scale=1)
837
- area_msg = gr.HTML("")
838
- gr.HTML('<div class="sec-label" style="margin-top:24px">Remove Area</div>')
839
- with gr.Row():
840
- del_area_dd = gr.Dropdown(label="Select area to remove", choices=[], scale=4)
841
- btn_del_area = gr.Button("Remove", elem_classes="btn-danger", scale=1)
842
- del_area_msg = gr.HTML("")
843
- gr.HTML('<hr>')
844
- gr.HTML('<div class="sec-label">Big-Picture Goals</div>')
845
- gr.HTML('<p style="color:#475569;font-size:13px;margin-bottom:10px">The AI factors these in when scheduling and prioritising.</p>')
846
- goals_input = gr.Textbox(label="One goal per line", lines=5, placeholder="Launch my SaaS\nRun 5K\nLearn ML")
847
- btn_save_goals = gr.Button("Save Goals", elem_classes="btn-primary")
848
- goals_msg = gr.HTML("")
849
-
850
- # TAB 5: PREFERENCES
851
- with gr.Tab("\u2699\ufe0f Preferences"):
852
- gr.HTML('<div class="sec-label">Scheduling Preferences</div>')
853
- with gr.Row():
854
- pref_wake = gr.Textbox(label="Wake time", placeholder="07:30", scale=1)
855
- pref_sleep = gr.Textbox(label="Sleep time", placeholder="23:00", scale=1)
856
- pref_focus = gr.Dropdown(label="Peak focus", choices=["Morning","Afternoon","Evening","Night"], value="Morning", scale=1)
857
- with gr.Row():
858
- pref_break = gr.Number(label="Break between tasks (min)", value=10, minimum=0, scale=1)
859
- pref_flow_max = gr.Number(label="Max Flow block (min)", value=90, minimum=30, scale=1)
860
- btn_save_prefs = gr.Button("Save Preferences", elem_classes="btn-primary")
861
- prefs_msg = gr.HTML("")
862
- gr.HTML('<hr>')
863
- gr.HTML('<div class="sec-label">AI Memory</div>')
864
- gr.HTML('<p style="color:#475569;font-size:13px;margin-bottom:10px">What the AI has learned about you. Updates after each journal session.</p>')
865
- ctx_display = gr.HTML("")
866
- btn_ref_ctx = gr.Button("\u21bb Refresh", elem_classes="btn-secondary")
867
-
868
-
869
- # =========================================================================
870
- # EVENT WIRING
871
- # =========================================================================
872
-
873
- def _header_html(uid, uname):
874
- return f'<div id="top-header-brand">\U0001f9e0 Second Brain</div><div id="top-header-user">\U0001f464 {uname}</div>'
875
-
876
- # Auth
877
- login_btn.click(handle_login, [login_user_in, login_pass_in],
878
- [user_id_state, username_state, login_msg, auth_section, app_section]
879
- ).then(_header_html, [user_id_state, username_state], [header_html]
880
- ).then(refresh_today, [user_id_state], [today_df, stat_total, stat_done, stat_remain]
881
- ).then(refresh_all_tasks_auto, [user_id_state], [all_df]
882
- ).then(lambda uid: render_areas(uid)[:2], [user_id_state], [areas_display, del_area_dd]
883
- ).then(load_prefs, [user_id_state], [pref_wake, pref_sleep, pref_focus, pref_break, pref_flow_max]
884
- ).then(load_goals_txt, [user_id_state], [goals_input])
885
-
886
- reg_btn.click(handle_register, [reg_user_in, reg_pass_in, reg_wake, reg_sleep, reg_focus, reg_goals],
887
- [user_id_state, username_state, reg_msg, auth_section, app_section]
888
- ).then(_header_html, [user_id_state, username_state], [header_html]
889
- ).then(refresh_today, [user_id_state], [today_df, stat_total, stat_done, stat_remain]
890
- ).then(refresh_all_tasks_auto, [user_id_state], [all_df]
891
- ).then(lambda uid: render_areas(uid)[:2], [user_id_state], [areas_display, del_area_dd])
892
-
893
- logout_btn.click(handle_logout, [user_id_state], [user_id_state, username_state, auth_section, app_section])
894
-
895
- # Panels
896
- btn_add_text.click(show_text_panel, outputs=[text_panel, voice_panel, plan_panel])
897
- btn_add_voice.click(show_voice_panel, outputs=[text_panel, voice_panel, plan_panel])
898
- btn_plan_day.click(show_plan_panel, outputs=[text_panel, voice_panel, plan_panel])
899
- btn_cancel_text.click(hide_panels, outputs=[text_panel, voice_panel, plan_panel])
900
- btn_cancel_voice.click(hide_panels, outputs=[text_panel, voice_panel, plan_panel])
901
- btn_cancel_plan.click(hide_panels, outputs=[text_panel, voice_panel, plan_panel])
902
- btn_refresh.click(refresh_today, [user_id_state], [today_df, stat_total, stat_done, stat_remain])
903
-
904
- f_is_habit.change(lambda v: gr.update(visible=v), [f_is_habit], [f_interval])
905
- vf_is_habit.change(lambda v: gr.update(visible=v), [vf_is_habit], [vf_interval])
906
-
907
- # Text task flow
908
- # Outputs: parsed_panel, f_title, clar_html, f_area, f_urgency, f_importance,
909
- # f_state, f_time, f_deadline, f_is_habit, f_interval, parse_status,
910
- # clar_reply_row, clar_questions_state, original_task_state
911
- _TEXT_PARSE_OUT = [parsed_panel, f_title, clar_html, f_area, f_urgency, f_importance,
912
- f_state, f_time, f_deadline, f_is_habit, f_interval, parse_status,
913
- clar_reply_row, clar_questions_state, original_task_state]
914
- # clar reply outputs: f_title, clar_html, f_area, f_urgency, f_importance,
915
- # f_state, f_time, f_deadline, clar_reply_row, clar_questions_state
916
- _TEXT_CLAR_OUT = [f_title, clar_html, f_area, f_urgency, f_importance,
917
- f_state, f_time, f_deadline, clar_reply_row, clar_questions_state]
918
-
919
- btn_parse.click(handle_parse_text, [task_input, user_id_state], _TEXT_PARSE_OUT)
920
-
921
- btn_clar_submit.click(
922
- handle_clarification_reply,
923
- [clar_text_input, original_task_state, clar_questions_state, user_id_state],
924
- _TEXT_CLAR_OUT
925
- ).then(lambda: ("", None), outputs=[clar_text_input, clar_audio_input])
926
-
927
- clar_audio_input.change(
928
- handle_clar_voice,
929
- [clar_audio_input, original_task_state, clar_questions_state, user_id_state],
930
- _TEXT_CLAR_OUT
931
- ).then(lambda: None, outputs=[clar_audio_input])
932
-
933
- btn_discard.click(lambda: gr.update(visible=False), outputs=[parsed_panel])
934
- # Confirm saves task + refreshes BOTH today and all_tasks
935
- btn_confirm.click(
936
- handle_confirm_task,
937
- [user_id_state, f_title, f_area, f_urgency, f_importance, f_state, f_time, f_deadline, f_is_habit, f_interval],
938
- [save_msg, today_df, stat_total, stat_done, stat_remain, all_df, parsed_panel]
939
- )
940
-
941
- # Voice task flow
942
- _VOICE_PARSE_OUT = [voice_parsed_panel, voice_transcribed_txt,
943
- voice_clar_html, vf_area, vf_urgency, vf_importance,
944
- vf_state, vf_time, vf_deadline, vf_is_habit, vf_interval,
945
- voice_status, voice_clar_row,
946
- vclar_questions_state, voriginal_task_state]
947
- _VOICE_CLAR_OUT = [vf_title, voice_clar_html, vf_area, vf_urgency, vf_importance,
948
- vf_state, vf_time, vf_deadline, voice_clar_row, vclar_questions_state]
949
-
950
- btn_voice_parse.click(handle_voice_parse, [voice_audio_input, user_id_state], _VOICE_PARSE_OUT)
951
-
952
- btn_vclar_submit.click(
953
- handle_clarification_reply,
954
- [vclar_text, voriginal_task_state, vclar_questions_state, user_id_state],
955
- _VOICE_CLAR_OUT
956
- ).then(lambda: ("", None), outputs=[vclar_text, vclar_audio])
957
-
958
- vclar_audio.change(
959
- handle_clar_voice,
960
- [vclar_audio, voriginal_task_state, vclar_questions_state, user_id_state],
961
- _VOICE_CLAR_OUT
962
- ).then(lambda: None, outputs=[vclar_audio])
963
-
964
- btn_voice_discard.click(lambda: gr.update(visible=False), outputs=[voice_parsed_panel])
965
- btn_voice_confirm.click(
966
- handle_confirm_task,
967
- [user_id_state, vf_title, vf_area, vf_urgency, vf_importance, vf_state, vf_time, vf_deadline, vf_is_habit, vf_interval],
968
- [voice_save_msg, today_df, stat_total, stat_done, stat_remain, all_df, voice_parsed_panel]
969
- )
970
-
971
- # Smart scheduler — updates both schedule_html and all_df
972
- btn_gen_sched.click(
973
- handle_smart_schedule, [user_id_state, plan_prompt],
974
- [schedule_html, all_df]
975
- ).then(refresh_today, [user_id_state], [today_df, stat_total, stat_done, stat_remain])
976
-
977
- btn_today_done.click(handle_toggle_today, [today_done_id, user_id_state], [today_df, stat_total, stat_done, stat_remain, today_action_msg])
978
- btn_today_del.click(handle_delete_today, [today_del_id, user_id_state], [today_df, stat_total, stat_done, stat_remain, today_action_msg])
979
-
980
- # All Tasks
981
- def refresh_all_tasks_filtered(user_id, filter_area, only_today, only_unsched):
982
- if not user_id: return []
983
- return _fmt_tasks(get_tasks(user_id, filter_area=filter_area, only_today=only_today, only_unscheduled=only_unsched))
984
-
985
- btn_all_ref.click(refresh_all_tasks_filtered, [user_id_state, all_filter, all_today_chk, all_unsched], [all_df])
986
- all_filter.change(refresh_all_tasks_filtered, [user_id_state, all_filter, all_today_chk, all_unsched], [all_df])
987
- all_today_chk.change(refresh_all_tasks_filtered,[user_id_state, all_filter, all_today_chk, all_unsched], [all_df])
988
- all_unsched.change(refresh_all_tasks_filtered, [user_id_state, all_filter, all_today_chk, all_unsched], [all_df])
989
-
990
- def handle_toggle_all_f(task_id, user_id, filter_area, only_today, only_unsched):
991
- if task_id and user_id: toggle_task_complete(int(task_id), user_id)
992
- return refresh_all_tasks_filtered(user_id, filter_area, only_today, only_unsched), _ok("Updated")
993
- def handle_delete_all_f(task_id, user_id, filter_area, only_today, only_unsched):
994
- if task_id and user_id: delete_task(int(task_id), user_id)
995
- return refresh_all_tasks_filtered(user_id, filter_area, only_today, only_unsched), _ok("Deleted")
996
-
997
- btn_all_done.click(handle_toggle_all_f, [all_done_id, user_id_state, all_filter, all_today_chk, all_unsched], [all_df, all_action_msg])
998
- btn_all_del.click(handle_delete_all_f, [all_del_id, user_id_state, all_filter, all_today_chk, all_unsched], [all_df, all_action_msg])
999
-
1000
- # Journal
1001
- _J_START_OUT = [journal_chatbot, journal_hist_state, journal_active,
1002
- j_answer, btn_j_send, j_audio_in, journal_tasks_state,
1003
- journal_phase_state, journal_msg]
1004
- _J_MSG_IN = [user_id_state, j_answer, j_audio_in, journal_chatbot,
1005
- journal_hist_state, journal_tasks_state, journal_active, journal_phase_state]
1006
- _J_MSG_OUT = [journal_chatbot, journal_hist_state, j_answer, j_audio_in,
1007
- journal_tasks_state, journal_active, journal_phase_state, journal_msg]
1008
-
1009
- btn_j_start.click(handle_start_journal, [user_id_state], _J_START_OUT)
1010
- btn_j_send.click(handle_journal_message, _J_MSG_IN, _J_MSG_OUT)
1011
- j_answer.submit(handle_journal_message, _J_MSG_IN, _J_MSG_OUT)
1012
- j_audio_in.change(handle_journal_message, _J_MSG_IN, _J_MSG_OUT)
1013
-
1014
- btn_j_finish.click(
1015
- handle_finish_journal,
1016
- [user_id_state, journal_chatbot, journal_hist_state, journal_tasks_state],
1017
- [journal_chatbot, journal_msg]
1018
- )
1019
- btn_j_restart.click(handle_restart_journal, outputs=[
1020
- journal_chatbot, journal_hist_state, journal_active,
1021
- j_answer, btn_j_send, j_audio_in, journal_tasks_state,
1022
- journal_phase_state, journal_msg
1023
- ])
1024
-
1025
- # Areas & Goals
1026
- btn_add_area.click(
1027
- handle_add_area, [user_id_state, new_area_name, new_area_color],
1028
- [area_msg, areas_display, del_area_dd, new_area_name]
1029
- ).then(lambda uid: gr.update(choices=_area_choices(uid), value="All"), [user_id_state], [all_filter])
1030
-
1031
- btn_del_area.click(
1032
- handle_del_area, [user_id_state, del_area_dd],
1033
- [del_area_msg, areas_display, del_area_dd]
1034
- ).then(lambda uid: gr.update(choices=_area_choices(uid), value="All"), [user_id_state], [all_filter])
1035
-
1036
- btn_save_goals.click(handle_save_goals, [user_id_state, goals_input], [goals_msg])
1037
-
1038
- # Preferences
1039
- btn_save_prefs.click(
1040
- handle_save_prefs,
1041
- [user_id_state, pref_wake, pref_sleep, pref_focus, pref_break, pref_flow_max],
1042
- [prefs_msg]
1043
- )
1044
- btn_ref_ctx.click(render_context_html, [user_id_state], [ctx_display])
1045
-
1046
-
1047
- if __name__ == "__main__":
1048
- demo.launch(server_name="0.0.0.0", server_port=7860, css=CSS)
 
1
  """
2
+ core/database.py
3
+ SQLite persistence for Second Brain.
4
  """
5
 
6
+ import sqlite3
7
+ import json
8
+ import bcrypt
9
+ from datetime import datetime, date
10
+ from typing import Optional
11
+
12
+ DB_PATH = "second_brain.db"
13
+
14
+
15
+ def get_db() -> sqlite3.Connection:
16
+ conn = sqlite3.connect(DB_PATH)
17
+ conn.row_factory = sqlite3.Row
18
+ conn.execute("PRAGMA foreign_keys = ON")
19
+ return conn
20
+
21
+
22
+ def init_db():
23
+ conn = get_db()
24
+ c = conn.cursor()
25
+
26
+ c.execute("""
27
+ CREATE TABLE IF NOT EXISTS users (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ username TEXT UNIQUE NOT NULL,
30
+ password_hash TEXT NOT NULL,
31
+ created_at TEXT DEFAULT (datetime('now'))
32
+ )
33
+ """)
34
+
35
+ c.execute("""
36
+ CREATE TABLE IF NOT EXISTS life_areas (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ user_id INTEGER NOT NULL,
39
+ name TEXT NOT NULL,
40
+ color TEXT DEFAULT '#6366f1',
41
+ created_at TEXT DEFAULT (datetime('now')),
42
+ FOREIGN KEY (user_id) REFERENCES users(id)
43
+ )
44
+ """)
45
+
46
+ c.execute("""
47
+ CREATE TABLE IF NOT EXISTS user_goals (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ user_id INTEGER NOT NULL,
50
+ goal_text TEXT NOT NULL,
51
+ created_at TEXT DEFAULT (datetime('now')),
52
+ FOREIGN KEY (user_id) REFERENCES users(id)
53
+ )
54
+ """)
55
+
56
+ c.execute("""
57
+ CREATE TABLE IF NOT EXISTS tasks (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ user_id INTEGER NOT NULL,
60
+ title TEXT NOT NULL,
61
+ life_area TEXT DEFAULT '',
62
+ urgency TEXT DEFAULT 'Not Urgent',
63
+ importance TEXT DEFAULT 'Important',
64
+ state_of_mind TEXT DEFAULT 'Easy',
65
+ time_estimate INTEGER DEFAULT 30,
66
+ scheduled_date TEXT DEFAULT '',
67
+ deadline_date TEXT DEFAULT '',
68
+ is_completed INTEGER DEFAULT 0,
69
+ actual_duration INTEGER,
70
+ is_habit INTEGER DEFAULT 0,
71
+ habit_interval TEXT DEFAULT '',
72
+ raw_input TEXT DEFAULT '',
73
+ created_at TEXT DEFAULT (datetime('now')),
74
+ FOREIGN KEY (user_id) REFERENCES users(id)
75
+ )
76
+ """)
77
+
78
+ # Migrate existing DBs — add deadline_date if missing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  try:
80
+ c.execute("ALTER TABLE tasks ADD COLUMN deadline_date TEXT DEFAULT ''")
 
81
  except Exception:
82
+ pass # column already exists
83
 
84
+ # Migrate existing DBs — clear default date so old tasks aren't shown as today
85
+ # (only affects new installs; existing data keeps its dates)
86
 
87
+ c.execute("""
88
+ CREATE TABLE IF NOT EXISTS user_context (
89
+ user_id INTEGER PRIMARY KEY,
90
+ context TEXT NOT NULL,
91
+ updated_at TEXT DEFAULT (datetime('now')),
92
+ FOREIGN KEY (user_id) REFERENCES users(id)
93
+ )
94
+ """)
 
 
 
 
 
95
 
96
+ conn.commit()
97
+ conn.close()
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ # ── Auth ──────────────────────────────────────────────────────────────────────
101
 
102
+ def register_user(username: str, password: str) -> tuple:
103
+ username = username.strip().lower()
104
+ if not username or not password:
105
+ return None, "Username and password cannot be empty."
106
+ if len(password) < 6:
107
+ return None, "Password must be at least 6 characters."
108
+ conn = get_db()
109
+ try:
110
+ pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
111
+ conn.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, pw_hash))
112
+ conn.commit()
113
+ row = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
114
+ return row["id"], "Account created!"
115
+ except sqlite3.IntegrityError:
116
+ return None, "Username already taken."
117
+ finally:
118
+ conn.close()
119
+
120
+
121
+ def login_user(username: str, password: str) -> tuple:
122
+ username = username.strip().lower()
123
+ if not username or not password:
124
+ return None, "Please enter your credentials."
125
+ conn = get_db()
126
+ row = conn.execute("SELECT id, password_hash FROM users WHERE username = ?", (username,)).fetchone()
127
+ conn.close()
128
+ if not row:
129
+ return None, "Username not found."
130
+ if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
131
+ return None, "Incorrect password."
132
+ return row["id"], f"Welcome back, {username}!"
133
+
134
+
135
+ def get_username(user_id: int) -> str:
136
+ conn = get_db()
137
+ row = conn.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
138
+ conn.close()
139
+ return row["username"].capitalize() if row else "User"
140
+
141
+
142
+ # ── Life Areas ────────────────────────────────────────────────────────────────
143
+
144
+ DEFAULT_AREAS = [
145
+ ("Work", "#4F8EF7"), ("Health", "#4CAF87"), ("Finance", "#F7A84F"),
146
+ ("Learning", "#A855F7"), ("Personal", "#EC4899"), ("Family", "#F59E0B"),
147
+ ]
148
+
149
+ def create_default_life_areas(user_id: int):
150
+ conn = get_db()
151
+ for name, color in DEFAULT_AREAS:
152
+ conn.execute("INSERT INTO life_areas (user_id, name, color) VALUES (?, ?, ?)", (user_id, name, color))
153
+ conn.commit()
154
+ conn.close()
155
+
156
+ def get_life_areas(user_id: int) -> list:
157
+ conn = get_db()
158
+ rows = conn.execute("SELECT id, name, color FROM life_areas WHERE user_id = ? ORDER BY id", (user_id,)).fetchall()
159
+ conn.close()
160
+ return [dict(r) for r in rows]
161
+
162
+ def get_life_area_names(user_id: int) -> list:
163
+ return [a["name"] for a in get_life_areas(user_id)]
164
+
165
+ def add_life_area(user_id: int, name: str, color: str = "#6366f1") -> tuple:
166
+ name = name.strip()
167
+ if not name: return False, "Name cannot be empty."
168
+ conn = get_db()
169
+ exists = conn.execute("SELECT id FROM life_areas WHERE user_id = ? AND LOWER(name) = LOWER(?)", (user_id, name)).fetchone()
170
+ if exists:
171
+ conn.close(); return False, f'"{name}" already exists.'
172
+ conn.execute("INSERT INTO life_areas (user_id, name, color) VALUES (?, ?, ?)", (user_id, name, color))
173
+ conn.commit(); conn.close()
174
+ return True, f'"{name}" added.'
175
+
176
+ def delete_life_area(user_id: int, name: str) -> tuple:
177
+ conn = get_db()
178
+ conn.execute("DELETE FROM life_areas WHERE user_id = ? AND name = ?", (user_id, name))
179
+ conn.commit(); conn.close()
180
+ return True, f'"{name}" removed.'
181
+
182
+
183
+ # ── Goals ─────────────────────────────────────────────────────────────────────
184
+
185
+ def save_goals(user_id: int, goals_text: str):
186
+ conn = get_db()
187
+ conn.execute("DELETE FROM user_goals WHERE user_id = ?", (user_id,))
188
+ for line in goals_text.strip().splitlines():
189
+ line = line.strip("•- ").strip()
190
+ if line:
191
+ conn.execute("INSERT INTO user_goals (user_id, goal_text) VALUES (?, ?)", (user_id, line))
192
+ conn.commit(); conn.close()
193
+
194
+ def get_goals(user_id: int) -> list:
195
+ conn = get_db()
196
+ rows = conn.execute("SELECT goal_text FROM user_goals WHERE user_id = ? ORDER BY id", (user_id,)).fetchall()
197
+ conn.close()
198
+ return [r["goal_text"] for r in rows]
199
+
200
+
201
+ # ── Tasks ─────────────────────────────────────────────────────────────────────
202
+
203
+ def save_task(user_id: int, task: dict, scheduled_date: str = None) -> int:
204
  """
205
+ Save a task. scheduled_date=None means unscheduled (awaiting AI assignment).
206
+ Pass scheduled_date="" explicitly to also leave unscheduled.
 
207
  """
208
+ conn = get_db()
209
+ cursor = conn.execute("""
210
+ INSERT INTO tasks
211
+ (user_id, title, life_area, urgency, importance, state_of_mind,
212
+ time_estimate, scheduled_date, deadline_date, raw_input, is_habit, habit_interval)
213
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
214
+ """, (
215
+ user_id,
216
+ task.get("title", "Untitled"),
217
+ task.get("life_area", ""),
218
+ task.get("urgency", "Not Urgent"),
219
+ task.get("importance", "Important"),
220
+ task.get("state_of_mind", "Easy"),
221
+ int(task.get("time_estimate") or 30),
222
+ scheduled_date if scheduled_date is not None else "", # "" = unscheduled
223
+ task.get("deadline_date", ""),
224
+ task.get("raw_input", ""),
225
+ 1 if task.get("is_habit") else 0,
226
+ task.get("habit_interval", ""),
227
+ ))
228
+ task_id = cursor.lastrowid
229
+ conn.commit(); conn.close()
230
+ return task_id
231
+
232
+
233
+ def assign_task_date(task_id: int, user_id: int, scheduled_date: str):
234
+ """Update a task's scheduled_date (called by the AI scheduler)."""
235
+ conn = get_db()
236
+ conn.execute(
237
+ "UPDATE tasks SET scheduled_date = ? WHERE id = ? AND user_id = ?",
238
+ (scheduled_date, task_id, user_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  )
240
+ conn.commit(); conn.close()
241
+
242
+
243
+ def get_tasks(user_id: int, filter_area: str = "All", only_today: bool = False,
244
+ include_completed: bool = True, only_unscheduled: bool = False) -> list:
245
+ conn = get_db()
246
+ q = "SELECT * FROM tasks WHERE user_id = ?"
247
+ params = [user_id]
248
+ if filter_area and filter_area != "All":
249
+ q += " AND life_area = ?"; params.append(filter_area)
250
+ if only_today:
251
+ q += " AND scheduled_date = ?"; params.append(str(date.today()))
252
+ if only_unscheduled:
253
+ q += " AND (scheduled_date = '' OR scheduled_date IS NULL)"
254
+ if not include_completed:
255
+ q += " AND is_completed = 0"
256
+ q += " ORDER BY is_completed ASC, CASE WHEN deadline_date = '' THEN '9999' ELSE deadline_date END ASC, created_at DESC"
257
+ rows = conn.execute(q, params).fetchall()
258
+ conn.close()
259
+ return [dict(r) for r in rows]
260
+
261
+
262
+ def toggle_task_complete(task_id: int, user_id: int, actual_duration: int = None):
263
+ conn = get_db()
264
+ task = conn.execute("SELECT is_completed FROM tasks WHERE id = ? AND user_id = ?", (task_id, user_id)).fetchone()
265
+ if task:
266
+ new_status = 1 - task["is_completed"]
267
+ if actual_duration and new_status == 1:
268
+ conn.execute("UPDATE tasks SET is_completed = ?, actual_duration = ? WHERE id = ? AND user_id = ?",
269
+ (new_status, actual_duration, task_id, user_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  else:
271
+ conn.execute("UPDATE tasks SET is_completed = ? WHERE id = ? AND user_id = ?",
272
+ (new_status, task_id, user_id))
273
+ conn.commit(); conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
 
 
 
 
275
 
276
+ def delete_task(task_id: int, user_id: int):
277
+ conn = get_db()
278
+ conn.execute("DELETE FROM tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
279
+ conn.commit(); conn.close()
280
 
281
 
282
+ def get_today_stats(user_id: int) -> dict:
283
+ tasks = get_tasks(user_id, only_today=True)
284
+ total = len(tasks); done = sum(1 for t in tasks if t["is_completed"])
285
+ return {"total": total, "done": done, "remaining": total - done}
286
+
287
+
288
+ # ── Habit recurrence ──────────────────────────────────────────────────────────
289
+
290
+ def spawn_due_habits(user_id: int):
291
+ today = str(date.today())
292
+ conn = get_db()
293
+ habits = conn.execute("SELECT * FROM tasks WHERE user_id = ? AND is_habit = 1", (user_id,)).fetchall()
294
+ for h in habits:
295
+ existing = conn.execute(
296
+ "SELECT id FROM tasks WHERE user_id = ? AND title = ? AND is_habit = 1 AND scheduled_date = ?",
297
+ (user_id, h["title"], today)
298
+ ).fetchone()
299
+ if existing: continue
300
+ if h["scheduled_date"] and h["scheduled_date"] >= today: continue
301
+ conn.execute("""
302
+ INSERT INTO tasks (user_id, title, life_area, urgency, importance,
303
+ state_of_mind, time_estimate, scheduled_date, is_habit,
304
+ habit_interval, raw_input)
305
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
306
+ """, (user_id, h["title"], h["life_area"], "Habit", h["importance"],
307
+ h["state_of_mind"], h["time_estimate"], today, h["habit_interval"], h["raw_input"]))
308
+ conn.commit(); conn.close()
309
+
310
+
311
+ # ── AI Context ────────────────────────────────────────────────────────────────
312
+
313
+ def load_user_context(user_id: int) -> Optional[dict]:
314
+ conn = get_db()
315
+ row = conn.execute("SELECT context FROM user_context WHERE user_id = ?", (user_id,)).fetchone()
316
+ conn.close()
317
+ if row:
318
+ try: return json.loads(row["context"])
319
+ except Exception: return None
320
+ return None
321
+
322
+
323
+ def save_user_context(user_id: int, context: dict):
324
+ conn = get_db()
325
+ conn.execute("""
326
+ INSERT INTO user_context (user_id, context, updated_at)
327
+ VALUES (?, ?, datetime('now'))
328
+ ON CONFLICT(user_id) DO UPDATE SET context = excluded.context, updated_at = excluded.updated_at
329
+ """, (user_id, json.dumps(context)))
330
+ conn.commit(); conn.close()