eraz3r commited on
Commit
519b0ae
Β·
verified Β·
1 Parent(s): c1c89e2

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +137 -7
  2. ai_engine.py +418 -0
  3. app.py +1086 -0
  4. database.py +386 -0
  5. requirements.txt +5 -0
  6. styles.py +190 -0
README.md CHANGED
@@ -1,14 +1,144 @@
1
  ---
2
  title: Second Brain
3
- emoji: 🐠
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.6.0
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
- short_description: Personalized task scheduling and journling
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Second Brain
3
+ emoji: 🧠
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: "4.20.0"
8
  app_file: app.py
9
  pinned: false
 
 
10
  ---
11
 
12
+ # 🧠 Second Brain
13
+
14
+ An intelligent personal productivity app that learns how you work and schedules your day accordingly.
15
+
16
+ ## Features
17
+
18
+ - **Task Capture** β€” type or speak tasks; AI classifies them across 4 dimensions automatically
19
+ - **Life Areas** β€” every task belongs to one of your personal life areas (Work, Health, etc.)
20
+ - **Smart Scheduling** β€” AI builds a time-blocked daily plan based on your tasks, context, and learned patterns
21
+ - **Habit Tracking** β€” mark tasks as habits with a recurrence interval; they auto-spawn each day
22
+ - **Goals** β€” define big-picture goals; AI factors them in when prioritizing
23
+ - **Journaling** β€” end-of-day reflection that updates the AI's knowledge of how you work best
24
+
25
+ ## Project Structure
26
+
27
+ ```
28
+ second_brain/
29
+ β”œβ”€β”€ app.py ← Main Gradio app, all UI + event wiring
30
+ β”œβ”€β”€ requirements.txt
31
+ β”œβ”€β”€ core/
32
+ β”‚ β”œβ”€β”€ __init__.py
33
+ β”‚ β”œβ”€β”€ database.py ← SQLite: users, tasks, life areas, goals, AI context
34
+ β”‚ β”œβ”€β”€ ai_engine.py ← All Groq API calls (task parsing, scheduling, journaling)
35
+ β”‚ └── styles.py ← CSS
36
+ └── ui/
37
+ └── __init__.py
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Deploying to HuggingFace Spaces
43
+
44
+ ### Prerequisites
45
+ - HuggingFace account with a Space already created (SDK: **Gradio**)
46
+ - Free Groq API key from [console.groq.com](https://console.groq.com)
47
+
48
+ ---
49
+
50
+ ### Step-by-step
51
+
52
+ #### 1. Add your Groq API key as a Secret
53
+
54
+ In your Space on HuggingFace:
55
+
56
+ 1. Go to **Settings** β†’ **Repository secrets**
57
+ 2. Click **New secret**
58
+ 3. Name: `GROQ_API_KEY`
59
+ 4. Value: your Groq key (starts with `gsk_`)
60
+ 5. Click **Add secret**
61
+
62
+ > ⚠️ Never paste your API key directly into any file β€” use secrets only.
63
+
64
+ ---
65
+
66
+ #### 2. Upload the files
67
+
68
+ You have two options:
69
+
70
+ **Option A β€” HuggingFace web UI (easiest)**
71
+
72
+ 1. Open your Space β†’ click **Files** tab
73
+ 2. Upload these files maintaining the folder structure:
74
+ ```
75
+ app.py
76
+ requirements.txt
77
+ core/__init__.py
78
+ core/database.py
79
+ core/ai_engine.py
80
+ core/styles.py
81
+ ui/__init__.py
82
+ ```
83
+ 3. The Space rebuilds automatically on each upload
84
+
85
+ **Option B β€” Git (recommended for updates)**
86
+
87
+ ```bash
88
+ # Clone your Space repo
89
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
90
+ cd YOUR_SPACE_NAME
91
+
92
+ # Copy project files in
93
+ cp -r /path/to/second_brain/* .
94
+
95
+ # Push
96
+ git add .
97
+ git commit -m "Initial deploy"
98
+ git push
99
+ ```
100
+
101
+ ---
102
+
103
+ #### 3. Wait for the build
104
+
105
+ - HuggingFace will install `requirements.txt` automatically
106
+ - First boot takes ~3–5 minutes (Whisper model downloads ~250MB)
107
+ - Watch the **Build logs** tab for progress
108
+
109
+ ---
110
+
111
+ #### 4. Verify it's running
112
+
113
+ - Open the Space URL
114
+ - You should see the Sign In / Create Account screen
115
+ - Create an account, add some tasks, and test the AI features
116
+
117
+ ---
118
+
119
+ ## Running Locally
120
+
121
+ ```bash
122
+ # Clone / download the project
123
+ cd second_brain
124
+
125
+ # Install dependencies
126
+ pip install -r requirements.txt
127
+
128
+ # Set your Groq API key
129
+ export GROQ_API_KEY="gsk_your_key_here"
130
+
131
+ # Run
132
+ python app.py
133
+ ```
134
+
135
+ Open `http://localhost:7860` in your browser.
136
+
137
+ ---
138
+
139
+ ## Notes
140
+
141
+ - **Database**: SQLite file `second_brain.db` is created automatically on first run in the app's working directory
142
+ - **Voice transcription**: Uses OpenAI Whisper `small` model (~250MB), downloaded on first voice task
143
+ - **AI model**: Groq `llama-3.3-70b-versatile` (free tier, ~0.3s response time)
144
+ - **Persistence on HF Spaces**: HuggingFace Spaces have ephemeral storage β€” the SQLite DB resets on each restart. For permanent storage, upgrade to a paid Space with persistent storage, or swap `database.py` to use an external DB (Supabase, PlanetScale, etc.)
ai_engine.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/ai_engine.py
3
+ All Groq API calls β€” prompts taken verbatim from the tested Colab notebook.
4
+ Covers: task parsing, scheduling, journaling Q&A, context synthesis.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import os
10
+ from datetime import datetime, date
11
+ from copy import deepcopy
12
+
13
+ from groq import Groq
14
+
15
+ GROQ_MODEL = "llama-3.3-70b-versatile"
16
+ _client: Groq = None
17
+
18
+
19
+ def init_groq(api_key: str = None):
20
+ global _client
21
+ key = api_key or os.environ.get("GROQ_API_KEY", "")
22
+ if not key:
23
+ raise ValueError(
24
+ "GROQ_API_KEY is not set. "
25
+ "Add it in HuggingFace Space β†’ Settings β†’ Repository secrets."
26
+ )
27
+ _client = Groq(api_key=key)
28
+
29
+
30
+ def _groq() -> Groq:
31
+ if _client is None:
32
+ init_groq()
33
+ return _client
34
+
35
+
36
+ # ── Shared util ───────────────────────────────────────────────────────────────
37
+
38
+ def safe_json_parse(text: str):
39
+ """Parse JSON, stripping markdown fences if present. Returns None on failure."""
40
+ try:
41
+ return json.loads(text)
42
+ except json.JSONDecodeError:
43
+ cleaned = re.sub(r'^```(?:json)?\s*|\s*```$', '', text, flags=re.MULTILINE).strip()
44
+ try:
45
+ return json.loads(cleaned)
46
+ except json.JSONDecodeError:
47
+ m = re.search(r'\{[\s\S]*\}', cleaned)
48
+ if m:
49
+ try:
50
+ return json.loads(m.group())
51
+ except Exception:
52
+ pass
53
+ return None
54
+
55
+
56
+ # ── Module 1: Task Capture ────────────────────────────────────────────────────
57
+
58
+ TASK_CAPTURE_PROMPT = """You are a task classification assistant for a productivity app called The Second Brain.
59
+
60
+ Your job is to take a user's raw task description and return a structured JSON object.
61
+
62
+ Classify the task across these dimensions:
63
+
64
+ 1. title (string): A clean, concise, action-oriented task title. Fix grammar.
65
+
66
+ 2. life_area (string): Choose ONE from: Work, Health, Learning, Finance, Personal, Family, Other
67
+ - Work: job, meetings, deadlines, clients, projects
68
+ - Health: exercise, medical, diet, mental health
69
+ - Learning: courses, books, skills, studying
70
+ - Finance: bills, payments, budgeting, taxes, investments
71
+ - Personal: hobbies, errands, home maintenance
72
+ - Family: tasks involving family members
73
+ - Other: does not fit any category
74
+
75
+ 3. urgency (string): Choose ONE from:
76
+ - Habit: recurring or routine task
77
+ - Urgent: hard deadline or time pressure
78
+ - Not Urgent: no specific deadline
79
+
80
+ 4. importance (string): Choose ONE from:
81
+ - Move the Needle: very high impact
82
+ - Important: meaningful, should be done
83
+ - Not Important: low real impact
84
+
85
+ 5. state_of_mind (string): Choose ONE from:
86
+ - Quick: 5-10 mins, very low focus
87
+ - Easy: 10-20 mins, low focus
88
+ - Flow: deep concentration needed
89
+ - Personal: life admin, little goal impact
90
+
91
+ 6. time_estimate (integer): Realistic minutes to complete.
92
+
93
+ 7. clarifications_needed (array of strings):
94
+ If NOT confident about a dimension, add a short specific question.
95
+ If everything is clear, return []
96
+
97
+ STRICT RULES:
98
+ - Return ONLY valid JSON. No markdown, no explanation.
99
+ - Never guess if uncertain β€” ask a clarification question instead.
100
+ - Always return all 7 fields.
101
+
102
+ Example: {"title": "Finish project proposal", "life_area": "Work", "urgency": "Urgent", "importance": "Move the Needle", "state_of_mind": "Flow", "time_estimate": 90, "clarifications_needed": []}"""
103
+
104
+
105
+ def parse_task_with_groq(raw_text: str, user_context: dict = None,
106
+ user_goals: list = None, life_areas: list = None) -> dict:
107
+ """Parse raw task text into structured dimensions using Groq."""
108
+ # Build context hint from AI memory + goals
109
+ context_hint = ""
110
+ if user_context and user_context.get("learned_patterns", {}).get("notes"):
111
+ notes = user_context["learned_patterns"]["notes"]
112
+ context_hint += f"\n\nUser context notes (use to inform classification): {'; '.join(notes[-3:])}"
113
+ if user_goals:
114
+ context_hint += f"\nUser goals: {'; '.join(user_goals[:5])}"
115
+ if life_areas:
116
+ context_hint += f"\nUser's life areas: {', '.join(life_areas)}"
117
+
118
+ response = _groq().chat.completions.create(
119
+ model=GROQ_MODEL,
120
+ messages=[
121
+ {"role": "system", "content": TASK_CAPTURE_PROMPT + context_hint},
122
+ {"role": "user", "content": f"Parse this task: {raw_text}"}
123
+ ],
124
+ max_tokens=512,
125
+ temperature=0.1,
126
+ )
127
+
128
+ result = safe_json_parse(response.choices[0].message.content.strip())
129
+ if result is None:
130
+ result = {
131
+ "title": raw_text,
132
+ "life_area": None, "urgency": None, "importance": None,
133
+ "state_of_mind": None, "time_estimate": None,
134
+ "clarifications_needed": [
135
+ "Could you give more details about this task?",
136
+ "Which area of your life does this belong to?",
137
+ "Is this urgent or flexible?"
138
+ ]
139
+ }
140
+ return result
141
+
142
+
143
+ # ── Module 2: Scheduling ──────────────────────────────────────────────────────
144
+
145
+ SCHEDULING_SYSTEM_PROMPT = """You are an intelligent daily scheduler for a productivity app called The Second Brain.
146
+
147
+ You receive a USER CONTEXT (preferences + learned patterns), a TASK LIST, and a SCHEDULING PROMPT.
148
+ Return a time-blocked schedule as a JSON object.
149
+
150
+ SCHEDULING RULES:
151
+ - Respect wake_time and sleep_time from context
152
+ - Place Flow tasks during the user's peak focus time
153
+ - If avg_task_overrun_pct > 0, add buffer proportionally to time estimates
154
+ - If flow_batch_capable is true, group Flow tasks; otherwise space them out
155
+ - Place Quick and Easy tasks around transitions and low-energy windows
156
+ - Place Personal/Habit tasks at day boundaries (start or end of day)
157
+ - Urgent tasks are scheduled before Not Urgent ones
158
+ - Move the Needle tasks get the best time slots
159
+ - Add 5-10 min breaks between tasks
160
+ - Respect any fixed commitments mentioned in the scheduling prompt
161
+ - Do NOT schedule past sleep_time
162
+ - If tasks won't realistically fit, put them in deferred_tasks
163
+ - If context is minimal (new user), use sensible defaults
164
+
165
+ RETURN FORMAT (JSON only, no markdown):
166
+ {
167
+ "schedule_date": "YYYY-MM-DD",
168
+ "scheduled_tasks": [
169
+ {
170
+ "task_id": "(id from input or index)",
171
+ "title": "...",
172
+ "life_area": "...",
173
+ "start_time": "HH:MM",
174
+ "end_time": "HH:MM",
175
+ "duration_minutes": 60,
176
+ "state_of_mind": "...",
177
+ "scheduling_reason": "1-sentence explanation"
178
+ }
179
+ ],
180
+ "deferred_tasks": [{"task_id": "...", "title": "...", "reason": "..."}],
181
+ "day_summary": "2-3 sentences on day structure and reasoning",
182
+ "warnings": ["any concerns e.g. day overloaded"]
183
+ }"""
184
+
185
+
186
+ def generate_schedule(context: dict, tasks: list, scheduling_prompt: str,
187
+ goals: list = None, schedule_date: str = None) -> dict:
188
+ if not schedule_date:
189
+ schedule_date = str(date.today())
190
+
191
+ goals_section = ""
192
+ if goals:
193
+ goals_section = "\nUSER GOALS:\n" + "\n".join(f"- {g}" for g in goals)
194
+
195
+ user_message = f"""Schedule Date: {schedule_date}
196
+
197
+ USER CONTEXT:
198
+ {json.dumps(context, indent=2)}
199
+ {goals_section}
200
+ TASKS TO SCHEDULE ({len(tasks)} tasks):
201
+ {json.dumps(tasks, indent=2)}
202
+
203
+ USER SCHEDULING PROMPT:
204
+ {scheduling_prompt}
205
+
206
+ Generate the optimal schedule."""
207
+
208
+ response = _groq().chat.completions.create(
209
+ model=GROQ_MODEL,
210
+ messages=[
211
+ {"role": "system", "content": SCHEDULING_SYSTEM_PROMPT},
212
+ {"role": "user", "content": user_message}
213
+ ],
214
+ max_tokens=2048,
215
+ temperature=0.2,
216
+ )
217
+
218
+ result = safe_json_parse(response.choices[0].message.content.strip())
219
+ if result is None:
220
+ result = {
221
+ "error": "Could not parse schedule response.",
222
+ "raw": response.choices[0].message.content
223
+ }
224
+
225
+ result["schedule_date"] = schedule_date
226
+ result["generated_at"] = datetime.now().isoformat()
227
+ return result
228
+
229
+
230
+ # ── Module 3: Journaling ──────────────────────────────────────────────────────
231
+
232
+ JOURNAL_QUESTION_PROMPT = """You are a reflective journaling coach for a productivity app called The Second Brain.
233
+
234
+ You receive the user's context, today's schedule with completion status, and the conversation so far.
235
+ Your job: decide what targeted question to ask NEXT.
236
+
237
+ FOCUS AREAS (cover what's most relevant, don't ask all):
238
+ - Tasks not completed β€” why? wrong time? too tired? overestimated?
239
+ - Tasks that took much longer than estimated
240
+ - Energy levels β€” when were they sharp vs drained?
241
+ - Whether Flow tasks were placed well or hard to start
242
+ - Whether the day felt balanced or overloaded
243
+ - Patterns the user noticed about themselves
244
+
245
+ RULES:
246
+ - Ask ONE question at a time. Short and specific.
247
+ - Build on previous answers β€” don't repeat covered ground.
248
+ - After 4-6 good exchanges, signal completion.
249
+ - Keep tone warm and efficient β€” 2-minute check-in, not therapy.
250
+
251
+ RETURN FORMAT (JSON only):
252
+ {"question": "Your next question", "question_focus": "what aspect this targets", "session_complete": false}
253
+ OR when done:
254
+ {"question": null, "question_focus": null, "session_complete": true}"""
255
+
256
+
257
+ SYNTHESIS_PROMPT = """You are a pattern recognition engine for a productivity app called The Second Brain.
258
+
259
+ You have a completed journaling conversation. Extract learnings and return the UPDATED user context JSON.
260
+
261
+ UPDATE these fields in learned_patterns based on conversation evidence:
262
+ - productive_times: when user felt sharp/focused
263
+ - low_energy_times: when they felt drained or skipped tasks
264
+ - avg_task_overrun_pct: recalculate from actual vs estimated times mentioned
265
+ - flow_batch_capable: update if user gave clear evidence
266
+ - best_life_areas_morning: what they completed well before noon
267
+ - common_skipped_task_types: patterns in what gets consistently skipped
268
+ - notes: append 1-2 new insight notes (keep existing ones)
269
+
270
+ ALWAYS UPDATE:
271
+ - scheduling_feedback.total_days_scheduled: +1
272
+ - scheduling_feedback.avg_completion_rate: rolling average
273
+ - scheduling_feedback.last_7_day_completion_rates: append today, keep last 7
274
+ - history_summary: append brief today summary, keep last 14
275
+ - last_updated: now
276
+ - version: +1
277
+
278
+ RULES:
279
+ - Return ONLY the complete updated context JSON. Nothing else.
280
+ - Never remove existing patterns β€” only update or append.
281
+ - Be conservative β€” only update if there is clear evidence in the conversation."""
282
+
283
+
284
+ def build_opening_question(context: dict, tasks_today: list) -> dict:
285
+ """Generate the first journal question based on task completion at a glance."""
286
+ total = len(tasks_today)
287
+ completed = sum(1 for t in tasks_today if t.get("completed", False))
288
+ incomplete = [t for t in tasks_today if not t.get("completed", False)]
289
+
290
+ if total == 0:
291
+ q = "It looks like you didn't have any tasks scheduled today β€” was that intentional or did things go sideways?"
292
+ elif completed == 0:
293
+ q = f"None of today's {total} tasks got marked complete β€” was the day unexpectedly derailed, or did the plan just not fit how your day went?"
294
+ elif completed == total:
295
+ q = f"You completed all {total} tasks today β€” great day! Did the schedule feel natural, or were you pushing through?"
296
+ elif len(incomplete) == 1:
297
+ q = f'You got almost everything done β€” the one task left was "{incomplete[0]["title"]}". What got in the way?'
298
+ else:
299
+ rate = round(completed / total * 100)
300
+ titles = ", ".join(f'"{t["title"]}"' for t in incomplete[:2])
301
+ q = f"You completed {completed}/{total} tasks ({rate}%). Tasks like {titles} didn't get done β€” was that time, energy, or something else?"
302
+
303
+ return {"question": q, "question_focus": "completion_overview", "session_complete": False}
304
+
305
+
306
+ def get_next_journal_question(context: dict, tasks_today: list,
307
+ conversation_history: list) -> dict:
308
+ user_message = f"""USER CONTEXT:
309
+ {json.dumps(context, indent=2)}
310
+
311
+ TODAY'S SCHEDULE (with completion):
312
+ {json.dumps(tasks_today, indent=2)}
313
+
314
+ CONVERSATION SO FAR ({len(conversation_history)} exchanges):
315
+ {json.dumps(conversation_history, indent=2)}
316
+
317
+ What should I ask next? Return session_complete: true if enough has been covered."""
318
+
319
+ response = _groq().chat.completions.create(
320
+ model=GROQ_MODEL,
321
+ messages=[
322
+ {"role": "system", "content": JOURNAL_QUESTION_PROMPT},
323
+ {"role": "user", "content": user_message}
324
+ ],
325
+ max_tokens=256,
326
+ temperature=0.3,
327
+ )
328
+
329
+ result = safe_json_parse(response.choices[0].message.content.strip())
330
+ if result is None:
331
+ result = {
332
+ "question": response.choices[0].message.content.strip(),
333
+ "question_focus": "general",
334
+ "session_complete": False
335
+ }
336
+ return result
337
+
338
+
339
+ def synthesize_journal(context: dict, tasks_today: list,
340
+ conversation_history: list) -> dict:
341
+ """Synthesize conversation into updated context. Fallback to manual stats update if LLM fails."""
342
+ total = len(tasks_today)
343
+ completed = sum(1 for t in tasks_today if t.get("completed", False))
344
+ completion_rate = round(completed / total, 2) if total > 0 else 0.0
345
+
346
+ user_message = f"""USER CONTEXT (current):
347
+ {json.dumps(context, indent=2)}
348
+
349
+ TODAY'S SCHEDULE + COMPLETION:
350
+ {json.dumps(tasks_today, indent=2)}
351
+
352
+ Today's completion rate: {completion_rate} ({completed}/{total})
353
+
354
+ FULL JOURNALING CONVERSATION:
355
+ {json.dumps(conversation_history, indent=2)}
356
+
357
+ Return the complete updated context JSON."""
358
+
359
+ response = _groq().chat.completions.create(
360
+ model=GROQ_MODEL,
361
+ messages=[
362
+ {"role": "system", "content": SYNTHESIS_PROMPT},
363
+ {"role": "user", "content": user_message}
364
+ ],
365
+ max_tokens=2048,
366
+ temperature=0.1,
367
+ )
368
+
369
+ updated = safe_json_parse(response.choices[0].message.content.strip())
370
+
371
+ if updated is None:
372
+ # Fallback: update stats manually if synthesis fails
373
+ updated = deepcopy(context)
374
+ updated["last_updated"] = datetime.now().isoformat()
375
+ updated["version"] = context.get("version", 1) + 1
376
+ sf = updated.setdefault("scheduling_feedback", {})
377
+ sf["total_days_scheduled"] = sf.get("total_days_scheduled", 0) + 1
378
+ rates = sf.get("last_7_day_completion_rates", [])
379
+ rates.append(completion_rate)
380
+ sf["last_7_day_completion_rates"] = rates[-7:]
381
+ sf["avg_completion_rate"] = round(sum(rates) / len(rates), 2)
382
+
383
+ return updated
384
+
385
+
386
+ # ── Context helpers ───────────────────────────────────────────────────────────
387
+
388
+ def create_blank_context(user_id, preferences: dict = None) -> dict:
389
+ prefs = preferences or {}
390
+ return {
391
+ "user_id": user_id,
392
+ "created_at": datetime.now().isoformat(),
393
+ "last_updated": datetime.now().isoformat(),
394
+ "version": 1,
395
+ "preferences": {
396
+ "wake_time": prefs.get("wake_time", "08:00"),
397
+ "sleep_time": prefs.get("sleep_time", "23:00"),
398
+ "focus_peak": prefs.get("focus_peak", "Morning"),
399
+ "break_duration_minutes": 10,
400
+ "max_flow_block_minutes": 90,
401
+ },
402
+ "learned_patterns": {
403
+ "productive_times": [],
404
+ "low_energy_times": [],
405
+ "avg_task_overrun_pct": 0,
406
+ "flow_batch_capable": None,
407
+ "best_life_areas_morning": [],
408
+ "habit_completion_rate": {},
409
+ "common_skipped_task_types": [],
410
+ "notes": []
411
+ },
412
+ "history_summary": [],
413
+ "scheduling_feedback": {
414
+ "total_days_scheduled": 0,
415
+ "avg_completion_rate": 0.0,
416
+ "last_7_day_completion_rates": []
417
+ }
418
+ }
app.py ADDED
@@ -0,0 +1,1086 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” Second Brain Gradio Application
3
+ ==========================================
4
+ Modular structure:
5
+ core/database.py β€” SQLite persistence (users, tasks, life areas, goals, AI context)
6
+ core/ai_engine.py β€” All Groq API calls (task parsing, scheduling, journaling)
7
+ core/styles.py β€” CSS
8
+
9
+ Run locally:
10
+ pip install -r requirements.txt
11
+ GROQ_API_KEY=gsk_... python app.py
12
+
13
+ HuggingFace Spaces:
14
+ Add GROQ_API_KEY in Space Settings β†’ Repository Secrets
15
+ """
16
+
17
+ import os
18
+ import gradio as gr
19
+ from datetime import date, datetime
20
+
21
+ # ── Core modules ──────────────────────────────────────────────────────────────
22
+ from core.database import (
23
+ init_db, register_user, login_user, get_username,
24
+ create_default_life_areas, save_goals, get_goals,
25
+ get_life_areas, get_life_area_names, add_life_area, delete_life_area,
26
+ get_tasks, save_task, toggle_task_complete, delete_task, get_today_stats,
27
+ load_user_context, save_user_context, spawn_due_habits,
28
+ )
29
+ from core.ai_engine import (
30
+ init_groq, create_blank_context,
31
+ parse_task_with_groq, generate_schedule,
32
+ build_opening_question, get_next_journal_question, synthesize_journal,
33
+ )
34
+ from core.styles import CSS
35
+
36
+ # ── Boot ──────────────────────────────────────────────────────────────────────
37
+ init_db()
38
+ init_groq() # reads GROQ_API_KEY from env
39
+
40
+ TASK_HEADERS = ["ID", "Done", "Title", "Area", "Urgency", "Importance", "Mind", "Min", "Date"]
41
+
42
+
43
+ # ═══════════════════════════════════════════════════════════════════════════════
44
+ # SHARED HELPERS
45
+ # ═══════════════════════════════════════════════════════════════════════════════
46
+
47
+ def _ok(msg): return f'<span style="color:#4ade80;font-size:13px">βœ“ {msg}</span>'
48
+ def _err(msg): return f'<span style="color:#f87171;font-size:13px">⚠ {msg}</span>'
49
+ def _info(msg): return f'<span style="color:#60a5fa;font-size:13px">β„Ή {msg}</span>'
50
+
51
+ def _stat(val, label, color):
52
+ return f'<div class="stat-card"><div class="stat-num" style="color:{color}">{val}</div><div class="stat-label">{label}</div></div>'
53
+
54
+ def _fmt_tasks(tasks: list) -> list:
55
+ rows = []
56
+ for t in tasks:
57
+ rows.append([
58
+ t["id"],
59
+ "βœ…" if t["is_completed"] else "⬜",
60
+ ("πŸ” " if t["is_habit"] else "") + t["title"],
61
+ t["life_area"] or "β€”",
62
+ t["urgency"] or "β€”",
63
+ t["importance"] or "β€”",
64
+ t["state_of_mind"] or "β€”",
65
+ str(t["time_estimate"]) + "m" if t["time_estimate"] else "β€”",
66
+ t["scheduled_date"] or "β€”",
67
+ ])
68
+ return rows
69
+
70
+ def _area_choices(user_id):
71
+ if not user_id:
72
+ return ["All"]
73
+ return ["All"] + get_life_area_names(user_id)
74
+
75
+ def _ensure_context(user_id):
76
+ """Load or create blank AI context for a user."""
77
+ ctx = load_user_context(user_id)
78
+ if not ctx:
79
+ ctx = create_blank_context(user_id)
80
+ save_user_context(user_id, ctx)
81
+ return ctx
82
+
83
+
84
+ # ═══════════════════════════════════════════════════════════════════════════════
85
+ # AUTH HANDLERS
86
+ # ═══════════════════════════════════════════════════════════════════════════════
87
+
88
+ def handle_login(username, password):
89
+ uid, msg = login_user(username, password)
90
+ if not uid:
91
+ return None, "", _err(msg), gr.update(visible=True), gr.update(visible=False)
92
+ spawn_due_habits(uid)
93
+ uname = get_username(uid)
94
+ return uid, uname, _ok(msg), gr.update(visible=False), gr.update(visible=True)
95
+
96
+
97
+ def handle_register(username, password, wake, sleep, focus, goals_text):
98
+ uid, msg = register_user(username, password)
99
+ if not uid:
100
+ return None, "", _err(msg), gr.update(visible=True), gr.update(visible=False)
101
+ create_default_life_areas(uid)
102
+ if goals_text.strip():
103
+ save_goals(uid, goals_text)
104
+ prefs = {"wake_time": wake or "07:30", "sleep_time": sleep or "23:00", "focus_peak": focus or "Morning"}
105
+ ctx = create_blank_context(uid, prefs)
106
+ save_user_context(uid, ctx)
107
+ spawn_due_habits(uid)
108
+ uname = get_username(uid)
109
+ return uid, uname, _ok(msg + " Logged in!"), gr.update(visible=False), gr.update(visible=True)
110
+
111
+
112
+ def handle_logout(user_id):
113
+ return None, "", gr.update(visible=True), gr.update(visible=False)
114
+
115
+
116
+ # ═══════════════════════════════════════════════════════════════════════════════
117
+ # TODAY TAB HANDLERS
118
+ # ═══════════════════════════════════════════════════════════════════════════════
119
+
120
+ def refresh_today(user_id):
121
+ if not user_id:
122
+ return [], _stat("β€”","Today","#a78bfa"), _stat("β€”","Done","#4ade80"), _stat("β€”","Left","#f87171")
123
+ tasks = get_tasks(user_id, only_today=True)
124
+ s = get_today_stats(user_id)
125
+ c = "#4ade80" if s["remaining"] == 0 and s["total"] > 0 else "#f87171"
126
+ return (
127
+ _fmt_tasks(tasks),
128
+ _stat(s["total"], "Today", "#a78bfa"),
129
+ _stat(s["done"], "Done", "#4ade80"),
130
+ _stat(s["remaining"], "Left", c),
131
+ )
132
+
133
+
134
+ def show_text_panel():
135
+ return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False)
136
+
137
+ def show_voice_panel():
138
+ return gr.update(visible=False), gr.update(visible=True), gr.update(visible=False)
139
+
140
+ def show_plan_panel():
141
+ return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True)
142
+
143
+ def hide_panels():
144
+ return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
145
+
146
+
147
+ def handle_parse_text(task_text, user_id):
148
+ """Call Groq to classify a typed task, return pre-filled fields."""
149
+ if not task_text.strip():
150
+ return (gr.update(visible=False),
151
+ "", "", "Work", "Not Urgent", "Important", "Easy", 30, str(date.today()), False, "Daily", "")
152
+
153
+ ctx = _ensure_context(user_id)
154
+ goals = get_goals(user_id)
155
+ areas = get_life_area_names(user_id) if user_id else []
156
+
157
+ result = parse_task_with_groq(task_text, ctx, goals, areas)
158
+
159
+ clarifications = result.get("clarifications_needed", [])
160
+ clar_html = ""
161
+ if clarifications:
162
+ items = "".join(f"<li style='margin:4px 0'>{q}</li>" for q in clarifications)
163
+ clar_html = (
164
+ f'<div style="margin:10px 0;padding:12px;background:#0c0f1a;'
165
+ f'border-left:3px solid #a78bfa;border-radius:8px">'
166
+ f'<div style="color:#a78bfa;font-size:11px;font-weight:600;margin-bottom:6px">⚠ Please clarify:</div>'
167
+ f'<ul style="color:#94a3b8;font-size:13px;margin:0;padding-left:16px">{items}</ul></div>'
168
+ )
169
+
170
+ area_choices = areas if areas else ["Work", "Health", "Finance", "Learning", "Personal", "Family", "Other"]
171
+ area_val = result.get("life_area") or (area_choices[0] if area_choices else "Work")
172
+ if area_val not in area_choices:
173
+ area_choices = [area_val] + area_choices
174
+
175
+ return (
176
+ gr.update(visible=True),
177
+ result.get("title", task_text),
178
+ clar_html,
179
+ area_val,
180
+ result.get("urgency", "Not Urgent"),
181
+ result.get("importance", "Important"),
182
+ result.get("state_of_mind", "Easy"),
183
+ int(result.get("time_estimate") or 30),
184
+ str(date.today()),
185
+ False,
186
+ "Daily",
187
+ _ok("AI classified your task"),
188
+ )
189
+
190
+
191
+ def handle_transcribe_voice(audio_path, user_id):
192
+ """Whisper transcription then Groq parse β€” same pipeline as notebook."""
193
+ if audio_path is None:
194
+ return gr.update(visible=False), _err("No audio recorded.")
195
+ try:
196
+ import whisper
197
+ global _whisper_model
198
+ if _whisper_model is None:
199
+ _whisper_model = whisper.load_model("small")
200
+ result = _whisper_model.transcribe(audio_path)
201
+ text = result["text"].strip()
202
+ return gr.update(visible=True, value=text), _ok(f'Transcribed: "{text[:60]}..."')
203
+ except Exception as e:
204
+ return gr.update(visible=False), _err(f"Transcription error: {e}")
205
+
206
+ _whisper_model = None
207
+
208
+
209
+ def handle_confirm_task(user_id, title, area, urgency, importance, state,
210
+ time_est, sched_date, is_habit, habit_interval):
211
+ if not user_id:
212
+ return _err("Not logged in."), [], _stat("β€”","Today","#a78bfa"), _stat("β€”","Done","#4ade80"), _stat("β€”","Left","#f87171"), gr.update(visible=False)
213
+ if not title.strip():
214
+ return _err("Title cannot be empty."), [], _stat("β€”","Today","#a78bfa"), _stat("β€”","Done","#4ade80"), _stat("β€”","Left","#f87171"), gr.update(visible=True)
215
+
216
+ task = {
217
+ "title": title, "life_area": area,
218
+ "urgency": urgency, "importance": importance, "state_of_mind": state,
219
+ "time_estimate": int(time_est or 30),
220
+ "is_habit": is_habit,
221
+ "habit_interval": habit_interval if is_habit else "",
222
+ }
223
+ save_task(user_id, task, sched_date)
224
+ rows, s1, s2, s3 = refresh_today(user_id)
225
+ return _ok("Task saved!"), rows, s1, s2, s3, gr.update(visible=False)
226
+
227
+
228
+ def handle_generate_schedule(user_id, prompt):
229
+ if not user_id:
230
+ return _err("Not logged in.")
231
+ tasks = get_tasks(user_id, only_today=True, include_completed=False)
232
+ if not tasks:
233
+ return _err("No incomplete tasks today to schedule.")
234
+
235
+ ctx = _ensure_context(user_id)
236
+ goals = get_goals(user_id)
237
+ sched = generate_schedule(ctx, tasks, prompt or "Schedule my day sensibly.", goals)
238
+
239
+ if "error" in sched and "scheduled_tasks" not in sched:
240
+ return _err(f"Scheduling failed: {sched.get('error')}")
241
+
242
+ COLOR = {"Flow": "#0ea5e9", "Easy": "#4ade80", "Quick": "#a78bfa", "Personal": "#f87171"}
243
+ html = (
244
+ f'<div style="margin-bottom:14px">'
245
+ f'<span style="color:#a78bfa;font-size:12px;font-weight:600">πŸ“… {sched.get("schedule_date","Today")}</span>'
246
+ f'<p style="color:#475569;font-size:13px;margin:6px 0 0">{sched.get("day_summary","")}</p>'
247
+ f'</div>'
248
+ )
249
+
250
+ for t in sched.get("scheduled_tasks", []):
251
+ sm = t.get("state_of_mind", "Easy")
252
+ c = COLOR.get(sm, "#6366f1")
253
+ html += (
254
+ f'<div class="sched-card" style="border-left-color:{c}">'
255
+ f'<div class="sched-time">{t.get("start_time")} – {t.get("end_time")}</div>'
256
+ f'<div class="sched-title">{t.get("title")}</div>'
257
+ f'<div class="sched-meta">{t.get("life_area","β€”")} Β· {sm} Β· {t.get("duration_minutes")}min</div>'
258
+ f'<div class="sched-why">{t.get("scheduling_reason","")}</div>'
259
+ f'</div>'
260
+ )
261
+
262
+ if sched.get("deferred_tasks"):
263
+ html += '<div style="margin-top:12px;color:#334155;font-size:11px;font-weight:600;letter-spacing:.6px;text-transform:uppercase">Deferred</div>'
264
+ for t in sched["deferred_tasks"]:
265
+ html += f'<div style="color:#475569;font-size:12px;padding:3px 0">βœ— {t["title"]} β€” {t.get("reason","")}</div>'
266
+
267
+ for w in sched.get("warnings", []):
268
+ html += f'<div style="margin-top:8px;color:#f59e0b;font-size:12px">⚠ {w}</div>'
269
+
270
+ return html
271
+
272
+
273
+ def handle_toggle_today(task_id, user_id):
274
+ if task_id and user_id:
275
+ toggle_task_complete(int(task_id), user_id)
276
+ rows, s1, s2, s3 = refresh_today(user_id)
277
+ return rows, s1, s2, s3, _ok("Updated")
278
+
279
+
280
+ def handle_delete_today(task_id, user_id):
281
+ if task_id and user_id:
282
+ delete_task(int(task_id), user_id)
283
+ rows, s1, s2, s3 = refresh_today(user_id)
284
+ return rows, s1, s2, s3, _ok("Deleted")
285
+
286
+
287
+ # ═══════════════════════════════════════════════════════════════════════════════
288
+ # ALL TASKS TAB HANDLERS
289
+ # ═══════════════════════════════════════════════════════════════════════════════
290
+
291
+ def refresh_all_tasks(user_id, filter_area="All", only_today=False):
292
+ if not user_id:
293
+ return []
294
+ return _fmt_tasks(get_tasks(user_id, filter_area=filter_area, only_today=only_today))
295
+
296
+
297
+ def handle_toggle_all(task_id, user_id, filter_area, only_today):
298
+ if task_id and user_id:
299
+ toggle_task_complete(int(task_id), user_id)
300
+ return refresh_all_tasks(user_id, filter_area, only_today), _ok("Updated")
301
+
302
+
303
+ def handle_delete_all(task_id, user_id, filter_area, only_today):
304
+ if task_id and user_id:
305
+ delete_task(int(task_id), user_id)
306
+ return refresh_all_tasks(user_id, filter_area, only_today), _ok("Deleted")
307
+
308
+
309
+ # ═══════════════════════════════════════════════════════════════════════════════
310
+ # JOURNAL TAB HANDLERS
311
+ # ═══════════════════════════════════════════════════════════════════════════════
312
+
313
+ def handle_load_journal_tasks(user_id):
314
+ if not user_id:
315
+ return "<p style='color:#f87171'>Not logged in.</p>", gr.update(visible=False), []
316
+
317
+ tasks = get_tasks(user_id, only_today=True)
318
+ if not tasks:
319
+ return "<p style='color:#475569;font-size:13px'>No tasks for today.</p>", gr.update(visible=False), []
320
+
321
+ rows = ""
322
+ state = []
323
+ for t in tasks:
324
+ status = "βœ…" if t["is_completed"] else "⬜"
325
+ actual = f" ({t['actual_duration']}m actual)" if t["actual_duration"] else ""
326
+ rows += (
327
+ f'<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid #1e293b">'
328
+ f'<span style="font-family:JetBrains Mono,monospace;color:#334155;font-size:11px;min-width:30px">#{t["id"]}</span>'
329
+ f'<span style="font-size:16px">{status}</span>'
330
+ f'<span style="flex:1;font-size:13px;color:#cbd5e1">{t["title"]}</span>'
331
+ f'<span style="font-size:11px;color:#475569">{t["life_area"] or "β€”"}</span>'
332
+ f'<span style="font-size:11px;color:#334155">{t["time_estimate"]}m{actual}</span>'
333
+ f'</div>'
334
+ )
335
+ state.append({
336
+ "task_id": t["id"], "title": t["title"],
337
+ "state_of_mind": t["state_of_mind"],
338
+ "start_time": "", "end_time": "",
339
+ "time_estimate": t["time_estimate"],
340
+ "completed": bool(t["is_completed"]),
341
+ "actual_duration": t["actual_duration"],
342
+ })
343
+
344
+ return f'<div style="font-size:12px">{rows}</div>', gr.update(visible=True), state
345
+
346
+
347
+ def handle_journal_mark(task_id, actual_mins, user_id, tasks_state, mark_done: bool):
348
+ if not task_id or not user_id:
349
+ return tasks_state, _err("Enter a task ID.")
350
+ tid = int(task_id)
351
+ if mark_done:
352
+ toggle_task_complete(tid, user_id, int(actual_mins) if actual_mins else None)
353
+ else:
354
+ toggle_task_complete(tid, user_id)
355
+ for t in tasks_state:
356
+ if t["task_id"] == tid:
357
+ t["completed"] = mark_done
358
+ if mark_done and actual_mins:
359
+ t["actual_duration"] = int(actual_mins)
360
+ return tasks_state, _ok("Marked βœ…" if mark_done else "Unmarked")
361
+
362
+
363
+ def handle_start_journal(user_id, tasks_state):
364
+ if not user_id:
365
+ return [], [], False, gr.update(interactive=False), gr.update(interactive=False), _err("Not logged in.")
366
+ if not tasks_state:
367
+ return [], [], False, gr.update(interactive=False), gr.update(interactive=False), _err("Load today's tasks first.")
368
+
369
+ ctx = _ensure_context(user_id)
370
+ opening = build_opening_question(ctx, tasks_state)
371
+ chat = [(None, opening["question"])]
372
+ hist = [{"role": "assistant", "content": opening["question"], "focus": opening["question_focus"]}]
373
+
374
+ return (
375
+ chat, hist, True,
376
+ gr.update(interactive=True),
377
+ gr.update(interactive=True),
378
+ _ok("Session started β€” type your answer below"),
379
+ )
380
+
381
+
382
+ def handle_send_answer(user_id, answer, chat, hist, tasks_state, active):
383
+ if not active or not answer.strip():
384
+ return chat, hist, "", ""
385
+
386
+ chat = list(chat) + [(answer, None)]
387
+ hist = list(hist) + [{"role": "user", "content": answer}]
388
+
389
+ ctx = _ensure_context(user_id)
390
+ result = get_next_journal_question(ctx, tasks_state, hist)
391
+
392
+ if result.get("session_complete"):
393
+ chat[-1] = (answer, "βœ… That's enough for today. Click **Finish & Save** to update your profile.")
394
+ return chat, hist, "", _ok("Session complete β€” save when ready")
395
+
396
+ next_q = result.get("question", "")
397
+ hist.append({"role": "assistant", "content": next_q, "focus": result.get("question_focus", "")})
398
+ chat[-1] = (answer, next_q)
399
+ return chat, hist, "", ""
400
+
401
+
402
+ def handle_finish_journal(user_id, chat, hist, tasks_state):
403
+ if not user_id or not hist:
404
+ return chat, _err("Nothing to save.")
405
+
406
+ ctx = _ensure_context(user_id)
407
+ updated = synthesize_journal(ctx, tasks_state, hist)
408
+ save_user_context(user_id, updated)
409
+
410
+ total = len(tasks_state)
411
+ done = sum(1 for t in tasks_state if t.get("completed"))
412
+ rate = round(done / total * 100) if total else 0
413
+ notes = updated.get("learned_patterns", {}).get("notes", [])
414
+ note_html = "".join(f"<li style='margin:3px 0'>{n}</li>" for n in notes[-3:]) if notes else "<li>No new patterns yet</li>"
415
+
416
+ return (
417
+ chat,
418
+ _ok(f"Insights saved! {done}/{total} tasks ({rate}%) completed.")
419
+ + f'<ul style="color:#94a3b8;font-size:12px;margin-top:6px;padding-left:16px">{note_html}</ul>'
420
+ )
421
+
422
+
423
+ def handle_restart_journal():
424
+ return [], [], False, gr.update(interactive=False), gr.update(interactive=False), ""
425
+
426
+
427
+ # ═══════════════════════════════════════════════════════════════════════════════
428
+ # LIFE AREAS & GOALS HANDLERS
429
+ # ═══════════════════════════════════════════════════════════════════════════════
430
+
431
+ def render_areas(user_id):
432
+ if not user_id:
433
+ return "", [], []
434
+ areas = get_life_areas(user_id)
435
+ chips = ""
436
+ for a in areas:
437
+ chips += (
438
+ f'<span class="chip" '
439
+ f'style="background:{a["color"]}22;color:{a["color"]};border:1px solid {a["color"]}44">'
440
+ f'{a["name"]}</span> '
441
+ )
442
+ html = f'<div style="margin:4px 0">{chips}</div>' if chips else '<p style="color:#334155;font-size:13px">No areas yet.</p>'
443
+ names = [a["name"] for a in areas]
444
+ return html, names, names
445
+
446
+
447
+ def handle_add_area(user_id, name, color):
448
+ ok, msg = add_life_area(user_id, name, color)
449
+ css = "ok" if ok else "err"
450
+ html, names, _ = render_areas(user_id)
451
+ return (
452
+ f'<span style="color:{"#4ade80" if ok else "#f87171"};font-size:13px">{msg}</span>',
453
+ html, gr.update(choices=names, value=None), gr.update(value=""),
454
+ )
455
+
456
+
457
+ def handle_del_area(user_id, name):
458
+ if not name:
459
+ return _err("Select an area first."), "", []
460
+ ok, msg = delete_life_area(user_id, name)
461
+ html, names, _ = render_areas(user_id)
462
+ return (
463
+ f'<span style="color:{"#4ade80" if ok else "#f87171"};font-size:13px">{msg}</span>',
464
+ html, gr.update(choices=names, value=None),
465
+ )
466
+
467
+
468
+ def handle_save_goals(user_id, goals_text):
469
+ if not user_id:
470
+ return _err("Not logged in.")
471
+ save_goals(user_id, goals_text)
472
+ return _ok("Goals saved!")
473
+
474
+
475
+ def load_goals_txt(user_id):
476
+ if not user_id:
477
+ return ""
478
+ return "\n".join(get_goals(user_id))
479
+
480
+
481
+ # ═══════════════════════════════════════════════════════════════════════════════
482
+ # PREFERENCES HANDLERS
483
+ # ═══════════════════════════════════════════════════════════════════════════════
484
+
485
+ def load_prefs(user_id):
486
+ ctx = load_user_context(user_id) if user_id else None
487
+ if not ctx:
488
+ return "07:30", "23:00", "Morning", 10, 90
489
+ p = ctx.get("preferences", {})
490
+ return (
491
+ p.get("wake_time", "07:30"),
492
+ p.get("sleep_time", "23:00"),
493
+ p.get("focus_peak", "Morning"),
494
+ p.get("break_duration_minutes", 10),
495
+ p.get("max_flow_block_minutes", 90),
496
+ )
497
+
498
+
499
+ def handle_save_prefs(user_id, wake, sleep, focus, brk, flow_max):
500
+ if not user_id:
501
+ return _err("Not logged in.")
502
+ ctx = _ensure_context(user_id)
503
+ ctx["preferences"].update({
504
+ "wake_time": wake or "07:30",
505
+ "sleep_time": sleep or "23:00",
506
+ "focus_peak": focus or "Morning",
507
+ "break_duration_minutes": int(brk or 10),
508
+ "max_flow_block_minutes": int(flow_max or 90),
509
+ })
510
+ save_user_context(user_id, ctx)
511
+ return _ok("Preferences saved!")
512
+
513
+
514
+ def render_context_html(user_id):
515
+ if not user_id:
516
+ return "<p style='color:#334155'>Not logged in.</p>"
517
+ ctx = load_user_context(user_id)
518
+ if not ctx:
519
+ return "<p style='color:#334155;font-size:13px'>No AI context yet. Complete a journaling session to start building it.</p>"
520
+
521
+ lp = ctx.get("learned_patterns", {})
522
+ sf = ctx.get("scheduling_feedback", {})
523
+ pref = ctx.get("preferences", {})
524
+
525
+ def row(label, val):
526
+ return (
527
+ f'<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #0d1117">'
528
+ f'<span style="color:#334155;font-size:12px">{label}</span>'
529
+ f'<span style="color:#94a3b8;font-size:12px;font-family:JetBrains Mono,monospace">{val}</span>'
530
+ f'</div>'
531
+ )
532
+ def lst(v): return ", ".join(v) if v else "β€”"
533
+
534
+ html = (
535
+ f'<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">'
536
+ f'<div class="panel">'
537
+ f'<div class="sec-label">Preferences</div>'
538
+ f'{row("Wake", pref.get("wake_time","β€”"))}'
539
+ f'{row("Sleep", pref.get("sleep_time","β€”"))}'
540
+ f'{row("Peak focus", pref.get("focus_peak","β€”"))}'
541
+ f'{row("Break", str(pref.get("break_duration_minutes","β€”")) + "min")}'
542
+ f'{row("Max flow block", str(pref.get("max_flow_block_minutes","β€”")) + "min")}'
543
+ f'</div>'
544
+ f'<div class="panel">'
545
+ f'<div class="sec-label">Scheduling Stats</div>'
546
+ f'{row("Days scheduled", sf.get("total_days_scheduled",0))}'
547
+ f'{row("Avg completion", str(round(sf.get("avg_completion_rate",0)*100)) + "%")}'
548
+ f'{row("Context version", ctx.get("version",1))}'
549
+ f'{row("Last updated", (ctx.get("last_updated","β€”") or "β€”")[:16])}'
550
+ f'</div>'
551
+ f'</div>'
552
+ f'<div class="panel" style="margin-top:14px">'
553
+ f'<div class="sec-label">Learned Patterns</div>'
554
+ f'{row("Productive times", lst(lp.get("productive_times",[])))}'
555
+ f'{row("Low energy times", lst(lp.get("low_energy_times",[])))}'
556
+ f'{row("Avg task overrun", str(lp.get("avg_task_overrun_pct",0)) + "%")}'
557
+ f'{row("Flow batchable", str(lp.get("flow_batch_capable","Unknown")))}'
558
+ f'{row("Best morning areas", lst(lp.get("best_life_areas_morning",[])))}'
559
+ f'{row("Skipped types", lst(lp.get("common_skipped_task_types",[])))}'
560
+ f'</div>'
561
+ )
562
+
563
+ notes = lp.get("notes", [])
564
+ if notes:
565
+ note_rows = "".join(
566
+ f'<div style="padding:5px 0;border-bottom:1px solid #0d1117;color:#64748b;font-size:12px">β€’ {n}</div>'
567
+ for n in notes[-5:]
568
+ )
569
+ html += f'<div class="panel" style="margin-top:14px"><div class="sec-label">AI Notes</div>{note_rows}</div>'
570
+
571
+ history = ctx.get("history_summary", [])
572
+ if history:
573
+ hist_rows = "".join(
574
+ f'<div style="padding:4px 0;color:#475569;font-size:12px">β€’ {h}</div>'
575
+ for h in history[-5:]
576
+ )
577
+ html += f'<div class="panel" style="margin-top:14px"><div class="sec-label">Recent History</div>{hist_rows}</div>'
578
+
579
+ return html
580
+
581
+
582
+ # ═══════════════════════════════════════════════════════════════════════════════
583
+ # UI BUILD
584
+ # ═══════════════════════════════════════════════════════════════════════════════
585
+
586
+ with gr.Blocks(css=CSS, title="🧠 Second Brain") as demo:
587
+
588
+ # ── Session state ──────────────────────────────────────────────────────────
589
+ user_id_state = gr.State(None)
590
+ username_state = gr.State("")
591
+ # Journal states
592
+ journal_chat_state = gr.State([])
593
+ journal_hist_state = gr.State([])
594
+ journal_tasks_state = gr.State([])
595
+ journal_active = gr.State(False)
596
+
597
+ # ═════════════════════════════════════════════════════════════════════════
598
+ # AUTH SECTION
599
+ # ═════════════════════════════════════════════════════════════════════════
600
+ with gr.Column(visible=True, elem_id="auth-card") as auth_section:
601
+ gr.HTML('<div id="brand-logo">🧠 Second Brain</div>')
602
+ gr.HTML('<div id="brand-sub">Your intelligent productivity companion</div>')
603
+
604
+ with gr.Tabs():
605
+ # ── Sign In ────────────────────────────────────────────────────
606
+ with gr.Tab("Sign In"):
607
+ login_user_in = gr.Textbox(label="Username", placeholder="your username")
608
+ login_pass_in = gr.Textbox(label="Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’")
609
+ login_btn = gr.Button("Sign In", elem_classes="btn-primary")
610
+ login_msg = gr.HTML("")
611
+
612
+ # ── Create Account ─────────────────────────────────────────────
613
+ with gr.Tab("Create Account"):
614
+ reg_user_in = gr.Textbox(label="Username", placeholder="choose a username")
615
+ reg_pass_in = gr.Textbox(label="Password (min 6 chars)", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’")
616
+ with gr.Row():
617
+ reg_wake = gr.Textbox(label="Wake time", value="07:30", scale=1)
618
+ reg_sleep = gr.Textbox(label="Sleep time", value="23:00", scale=1)
619
+ reg_focus = gr.Dropdown(label="Peak focus",
620
+ choices=["Morning","Afternoon","Evening","Night"],
621
+ value="Morning", scale=1)
622
+ reg_goals = gr.Textbox(
623
+ label="Big-picture goals (one per line, optional)",
624
+ lines=3,
625
+ placeholder="Launch my startup\nGet fit\nLearn machine learning"
626
+ )
627
+ reg_btn = gr.Button("Create Account", elem_classes="btn-primary")
628
+ reg_msg = gr.HTML("")
629
+
630
+ # ═════════════════════════════════════════════════════════════════════════
631
+ # MAIN APP SECTION
632
+ # ═════════════════════════════════════════════════════════════════════════
633
+ with gr.Column(visible=False) as app_section:
634
+
635
+ # ── Top header ─────────────────────────────────────────────────────
636
+ with gr.Row(elem_id="top-header"):
637
+ header_html = gr.HTML('<div id="top-header-brand">🧠 Second Brain</div><div id="top-header-user">β€”</div>')
638
+ logout_btn = gr.Button("Sign Out", elem_classes="btn-secondary", scale=0)
639
+
640
+ # ── Tabs ─────────────────────────────���──────────────────────────────
641
+ with gr.Tabs() as main_tabs:
642
+
643
+ # ─────────────────────────────────────────────────────────────
644
+ # TAB 1: TODAY
645
+ # ─────────────────────────────────────────────────────────────
646
+ with gr.Tab("πŸ“… Today"):
647
+ # Stats row
648
+ with gr.Row():
649
+ stat_total = gr.HTML(_stat("β€”","Today","#a78bfa"))
650
+ stat_done = gr.HTML(_stat("β€”","Done","#4ade80"))
651
+ stat_remain = gr.HTML(_stat("β€”","Left","#f87171"))
652
+
653
+ # Action buttons
654
+ with gr.Row():
655
+ btn_add_text = gr.Button("✏️ Add Task (Text)", elem_classes="btn-primary", scale=3)
656
+ btn_add_voice = gr.Button("πŸŽ™οΈ Add Task (Voice)", elem_classes="btn-accent", scale=3)
657
+ btn_plan_day = gr.Button("πŸ—“ Plan My Day", elem_classes="btn-secondary", scale=3)
658
+ btn_refresh = gr.Button("↻", elem_classes="btn-secondary", scale=1)
659
+
660
+ # ── Text add panel ────────────────────────────────────────
661
+ with gr.Column(visible=False, elem_classes="panel") as text_panel:
662
+ gr.HTML('<div class="sec-label">Describe Your Task</div>')
663
+ task_input = gr.Textbox(
664
+ label="",
665
+ placeholder="e.g. Finish the client proposal by tomorrow, it's really important",
666
+ lines=2
667
+ )
668
+ with gr.Row():
669
+ btn_parse = gr.Button("πŸ€– Parse with AI", elem_classes="btn-primary")
670
+ btn_cancel_text = gr.Button("Cancel", elem_classes="btn-secondary")
671
+
672
+ with gr.Column(visible=False) as parsed_panel:
673
+ parse_status = gr.HTML("")
674
+ clar_html = gr.HTML("")
675
+ gr.HTML('<div class="sec-label" style="margin-top:12px">Review & Confirm</div>')
676
+ with gr.Row():
677
+ f_title = gr.Textbox(label="Title", scale=3)
678
+ f_area = gr.Dropdown(label="Life Area",
679
+ choices=["Work","Health","Finance","Learning","Personal","Family","Other"],
680
+ scale=1)
681
+ with gr.Row():
682
+ f_urgency = gr.Dropdown(label="Urgency",
683
+ choices=["Urgent","Not Urgent","Habit"], value="Not Urgent")
684
+ f_importance = gr.Dropdown(label="Importance",
685
+ choices=["Move the Needle","Important","Not Important"],
686
+ value="Important")
687
+ f_state = gr.Dropdown(label="State of Mind",
688
+ choices=["Flow","Easy","Quick","Personal"], value="Easy")
689
+ with gr.Row():
690
+ f_time = gr.Number(label="Minutes", value=30, minimum=5, scale=1)
691
+ f_date = gr.Textbox(label="Scheduled Date", value=str(date.today()), scale=1)
692
+ f_is_habit = gr.Checkbox(label="♻️ This is a habit", scale=1)
693
+ f_interval = gr.Dropdown(label="Recurs",
694
+ choices=["Daily","Weekly","Weekdays","Weekends","Monthly"],
695
+ value="Daily", visible=False, scale=1)
696
+ with gr.Row():
697
+ btn_confirm = gr.Button("βœ“ Save Task", elem_classes="btn-success")
698
+ btn_discard = gr.Button("βœ— Discard", elem_classes="btn-danger")
699
+ save_msg = gr.HTML("")
700
+
701
+ # ── Voice add panel ───────────────────────────────────────
702
+ with gr.Column(visible=False, elem_classes="panel") as voice_panel:
703
+ gr.HTML('<div class="sec-label">Record or Upload Audio</div>')
704
+ gr.HTML('<p style="color:#334155;font-size:13px;margin-bottom:12px">Whisper transcribes it, then AI classifies the task automatically.</p>')
705
+ audio_input = gr.Audio(sources=["microphone","upload"], type="filepath", label="")
706
+ transcribed = gr.Textbox(label="Transcribed text (editable before parsing)",
707
+ visible=False, lines=2)
708
+ with gr.Row():
709
+ btn_transcribe = gr.Button("πŸ€– Transcribe & Parse", elem_classes="btn-primary")
710
+ btn_cancel_voice = gr.Button("Cancel", elem_classes="btn-secondary")
711
+ voice_msg = gr.HTML("")
712
+
713
+ # ── Plan My Day panel ─────────────────────────────────────
714
+ with gr.Column(visible=False, elem_classes="panel") as plan_panel:
715
+ gr.HTML('<div class="sec-label">Plan My Day</div>')
716
+ gr.HTML('<p style="color:#334155;font-size:13px;margin-bottom:12px">The AI reads your tasks, context, and goals to build the optimal schedule.</p>')
717
+ plan_prompt = gr.Textbox(
718
+ label="Any fixed events or preferences today?",
719
+ placeholder="e.g. I have a meeting at 10am, want deep work in the morning, done by 7pm",
720
+ lines=3
721
+ )
722
+ with gr.Row():
723
+ btn_gen_sched = gr.Button("✨ Generate Schedule", elem_classes="btn-primary")
724
+ btn_cancel_plan = gr.Button("Cancel", elem_classes="btn-secondary")
725
+ schedule_html = gr.HTML("")
726
+
727
+ # ── Today's task table ────────────────────────────────────
728
+ gr.HTML('<div class="sec-label" style="margin-top:20px">Today\'s Tasks</div>')
729
+ today_df = gr.Dataframe(headers=TASK_HEADERS, interactive=False, wrap=True)
730
+ with gr.Row():
731
+ today_done_id = gr.Number(label="Task ID β€” toggle done", precision=0, scale=2)
732
+ btn_today_done = gr.Button("βœ… Mark Done", elem_classes="btn-success", scale=2)
733
+ today_del_id = gr.Number(label="Task ID β€” delete", precision=0, scale=2)
734
+ btn_today_del = gr.Button("πŸ—‘ Delete", elem_classes="btn-danger", scale=2)
735
+ today_action_msg = gr.HTML("")
736
+
737
+ # ─────────────────────────────────────────────────────────────
738
+ # TAB 2: ALL TASKS
739
+ # ─────────────────────────────────────────────────────────────
740
+ with gr.Tab("πŸ“‹ All Tasks"):
741
+ with gr.Row():
742
+ all_filter = gr.Dropdown(label="Filter by Area", choices=["All"], value="All", scale=4)
743
+ all_today_chk = gr.Checkbox(label="Today only", value=False, scale=1)
744
+ btn_all_ref = gr.Button("↻ Refresh", elem_classes="btn-secondary", scale=1)
745
+
746
+ all_df = gr.Dataframe(headers=TASK_HEADERS, interactive=False, wrap=True)
747
+ with gr.Row():
748
+ all_done_id = gr.Number(label="Task ID β€” toggle done", precision=0, scale=2)
749
+ btn_all_done = gr.Button("βœ… Done", elem_classes="btn-success", scale=2)
750
+ all_del_id = gr.Number(label="Task ID β€” delete", precision=0, scale=2)
751
+ btn_all_del = gr.Button("πŸ—‘ Delete", elem_classes="btn-danger", scale=2)
752
+ all_action_msg = gr.HTML("")
753
+
754
+ # ─────────────────────────────────────────────────────────────
755
+ # TAB 3: JOURNAL
756
+ # ─────────────────────────────────────────────────────────────
757
+ with gr.Tab("πŸ““ Journal"):
758
+ gr.HTML('<p style="color:#334155;font-size:13px;margin-bottom:16px">Mark today\'s tasks complete, then reflect. The AI updates your profile to improve tomorrow\'s schedule.</p>')
759
+
760
+ # Step 1 β€” Mark tasks
761
+ with gr.Column(elem_classes="panel"):
762
+ gr.HTML('<div class="sec-label">Step 1 β€” Mark Tasks Complete</div>')
763
+ journal_task_list = gr.HTML('<p style="color:#334155;font-size:13px">Click "Load Tasks" to begin.</p>')
764
+ btn_load_tasks = gr.Button("Load Today's Tasks", elem_classes="btn-secondary")
765
+ with gr.Row(visible=False) as journal_mark_row:
766
+ j_task_id = gr.Number(label="Task ID", precision=0, scale=2)
767
+ j_actual_min = gr.Number(label="Actual time (min)", precision=0, value=0, scale=2)
768
+ btn_j_done = gr.Button("βœ… Mark Complete", elem_classes="btn-success", scale=2)
769
+ btn_j_undone = gr.Button("↩ Unmark", elem_classes="btn-secondary", scale=1)
770
+ journal_mark_msg = gr.HTML("")
771
+
772
+ # Step 2 β€” Chat
773
+ with gr.Column(elem_classes="panel"):
774
+ gr.HTML('<div class="sec-label">Step 2 β€” Reflect</div>')
775
+ journal_chatbot = gr.Chatbot(label="", height=380, bubble_full_width=False)
776
+ with gr.Row():
777
+ btn_j_start = gr.Button("β–Ά Start Reflection", elem_classes="btn-primary", scale=2)
778
+ btn_j_finish = gr.Button("βœ“ Finish & Save Insights", elem_classes="btn-success", scale=2)
779
+ btn_j_restart = gr.Button("β†Ί Restart", elem_classes="btn-secondary", scale=1)
780
+ j_answer = gr.Textbox(label="Your answer", placeholder="Type here…", lines=2, interactive=False)
781
+ btn_j_send = gr.Button("Send β†’", elem_classes="btn-accent", interactive=False)
782
+ journal_msg = gr.HTML("")
783
+
784
+ # ─────────────────────────────────────────────────────────────
785
+ # TAB 4: LIFE AREAS & GOALS
786
+ # ─────────────────────────────────────────────────────────────
787
+ with gr.Tab("πŸ—‚ Areas & Goals"):
788
+ gr.HTML('<div class="sec-label">Your Life Areas</div>')
789
+ areas_display = gr.HTML("")
790
+
791
+ with gr.Row():
792
+ new_area_name = gr.Textbox(label="New area name", placeholder="e.g. Side Project", scale=3)
793
+ new_area_color = gr.ColorPicker(label="Color", value="#6366f1", scale=1)
794
+ btn_add_area = gr.Button("Add", elem_classes="btn-primary", scale=1)
795
+ area_msg = gr.HTML("")
796
+
797
+ gr.HTML('<div class="sec-label" style="margin-top:24px">Remove Area</div>')
798
+ with gr.Row():
799
+ del_area_dd = gr.Dropdown(label="Select area to remove", choices=[], scale=4)
800
+ btn_del_area = gr.Button("Remove", elem_classes="btn-danger", scale=1)
801
+ del_area_msg = gr.HTML("")
802
+
803
+ gr.HTML('<hr>')
804
+ gr.HTML('<div class="sec-label">Big-Picture Goals</div>')
805
+ gr.HTML('<p style="color:#334155;font-size:13px;margin-bottom:10px">The AI uses these when scheduling and prioritising your tasks.</p>')
806
+ goals_input = gr.Textbox(label="One goal per line", lines=5,
807
+ placeholder="Launch my SaaS\nRun 5K\nLearn ML")
808
+ btn_save_goals = gr.Button("Save Goals", elem_classes="btn-primary")
809
+ goals_msg = gr.HTML("")
810
+
811
+ # ─────────────────────────────────────────────────────────────
812
+ # TAB 5: PREFERENCES
813
+ # ─────────────────────────────────────────────────────────────
814
+ with gr.Tab("βš™οΈ Preferences"):
815
+ gr.HTML('<div class="sec-label">Scheduling Preferences</div>')
816
+ with gr.Row():
817
+ pref_wake = gr.Textbox(label="Wake time", placeholder="07:30", scale=1)
818
+ pref_sleep = gr.Textbox(label="Sleep time", placeholder="23:00", scale=1)
819
+ pref_focus = gr.Dropdown(label="Peak focus",
820
+ choices=["Morning","Afternoon","Evening","Night"],
821
+ value="Morning", scale=1)
822
+ with gr.Row():
823
+ pref_break = gr.Number(label="Break between tasks (min)", value=10, minimum=0, scale=1)
824
+ pref_flow_max = gr.Number(label="Max Flow block (min)", value=90, minimum=30, scale=1)
825
+ btn_save_prefs = gr.Button("Save Preferences", elem_classes="btn-primary")
826
+ prefs_msg = gr.HTML("")
827
+
828
+ gr.HTML('<hr>')
829
+ gr.HTML('<div class="sec-label">AI Memory</div>')
830
+ gr.HTML('<p style="color:#334155;font-size:13px;margin-bottom:10px">Everything the AI has learned about you. Updates after each journaling session.</p>')
831
+ ctx_display = gr.HTML("")
832
+ btn_ref_ctx = gr.Button("↻ Refresh", elem_classes="btn-secondary")
833
+
834
+
835
+ # ═══════════════════════════════════════════════════════════════════════
836
+ # EVENT WIRING
837
+ # ══════════════════��════════════════════════════════════════════════════
838
+
839
+ # ── Auth ──────────────────────────────────────────────────────────────
840
+
841
+ def _post_login(uid, uname):
842
+ if not uid:
843
+ return uid, uname
844
+ spawn_due_habits(uid)
845
+ return uid, uname
846
+
847
+ login_btn.click(
848
+ handle_login,
849
+ [login_user_in, login_pass_in],
850
+ [user_id_state, username_state, login_msg, auth_section, app_section]
851
+ ).then(
852
+ lambda uid, uname: f'<div id="top-header-brand">🧠 Second Brain</div><div id="top-header-user">πŸ‘€ {uname}</div>',
853
+ [user_id_state, username_state], [header_html]
854
+ ).then(
855
+ refresh_today, [user_id_state],
856
+ [today_df, stat_total, stat_done, stat_remain]
857
+ ).then(
858
+ lambda uid: render_areas(uid)[0:2],
859
+ [user_id_state], [areas_display, del_area_dd]
860
+ ).then(
861
+ load_goals_txt, [user_id_state], [goals_input]
862
+ ).then(
863
+ lambda uid: load_prefs(uid),
864
+ [user_id_state], [pref_wake, pref_sleep, pref_focus, pref_break, pref_flow_max]
865
+ )
866
+
867
+ reg_btn.click(
868
+ handle_register,
869
+ [reg_user_in, reg_pass_in, reg_wake, reg_sleep, reg_focus, reg_goals],
870
+ [user_id_state, username_state, reg_msg, auth_section, app_section]
871
+ ).then(
872
+ lambda uid, uname: f'<div id="top-header-brand">🧠 Second Brain</div><div id="top-header-user">πŸ‘€ {uname}</div>',
873
+ [user_id_state, username_state], [header_html]
874
+ ).then(
875
+ refresh_today, [user_id_state],
876
+ [today_df, stat_total, stat_done, stat_remain]
877
+ ).then(
878
+ lambda uid: render_areas(uid)[0:2],
879
+ [user_id_state], [areas_display, del_area_dd]
880
+ )
881
+
882
+ logout_btn.click(
883
+ handle_logout,
884
+ [user_id_state],
885
+ [user_id_state, username_state, auth_section, app_section]
886
+ )
887
+
888
+ # ── Today tab ─────────────────────────────────────────────────────────
889
+
890
+ btn_add_text.click(show_text_panel, outputs=[text_panel, voice_panel, plan_panel])
891
+ btn_add_voice.click(show_voice_panel, outputs=[text_panel, voice_panel, plan_panel])
892
+ btn_plan_day.click(show_plan_panel, outputs=[text_panel, voice_panel, plan_panel])
893
+ btn_cancel_text.click(hide_panels, outputs=[text_panel, voice_panel, plan_panel])
894
+ btn_cancel_voice.click(hide_panels, outputs=[text_panel, voice_panel, plan_panel])
895
+ btn_cancel_plan.click(hide_panels, outputs=[text_panel, voice_panel, plan_panel])
896
+
897
+ btn_refresh.click(
898
+ refresh_today, [user_id_state],
899
+ [today_df, stat_total, stat_done, stat_remain]
900
+ )
901
+
902
+ # Show habit interval dropdown only when habit checked
903
+ f_is_habit.change(
904
+ lambda v: gr.update(visible=v),
905
+ [f_is_habit], [f_interval]
906
+ )
907
+
908
+ btn_parse.click(
909
+ handle_parse_text,
910
+ [task_input, user_id_state],
911
+ [parsed_panel, f_title, clar_html, f_area, f_urgency, f_importance,
912
+ f_state, f_time, f_date, f_is_habit, f_interval, parse_status]
913
+ )
914
+
915
+ btn_transcribe.click(
916
+ handle_transcribe_voice,
917
+ [audio_input, user_id_state],
918
+ [transcribed, voice_msg]
919
+ )
920
+
921
+ btn_discard.click(
922
+ lambda: gr.update(visible=False),
923
+ outputs=[parsed_panel]
924
+ )
925
+
926
+ btn_confirm.click(
927
+ handle_confirm_task,
928
+ [user_id_state, f_title, f_area, f_urgency, f_importance, f_state,
929
+ f_time, f_date, f_is_habit, f_interval],
930
+ [save_msg, today_df, stat_total, stat_done, stat_remain, parsed_panel]
931
+ )
932
+
933
+ btn_gen_sched.click(
934
+ handle_generate_schedule,
935
+ [user_id_state, plan_prompt],
936
+ [schedule_html]
937
+ )
938
+
939
+ btn_today_done.click(
940
+ handle_toggle_today,
941
+ [today_done_id, user_id_state],
942
+ [today_df, stat_total, stat_done, stat_remain, today_action_msg]
943
+ )
944
+ btn_today_del.click(
945
+ handle_delete_today,
946
+ [today_del_id, user_id_state],
947
+ [today_df, stat_total, stat_done, stat_remain, today_action_msg]
948
+ )
949
+
950
+ # ── All Tasks tab ──────────────────────────────────────────────────────
951
+
952
+ btn_all_ref.click(
953
+ refresh_all_tasks,
954
+ [user_id_state, all_filter, all_today_chk],
955
+ [all_df]
956
+ )
957
+ all_filter.change(
958
+ refresh_all_tasks,
959
+ [user_id_state, all_filter, all_today_chk],
960
+ [all_df]
961
+ )
962
+ all_today_chk.change(
963
+ refresh_all_tasks,
964
+ [user_id_state, all_filter, all_today_chk],
965
+ [all_df]
966
+ )
967
+ btn_all_done.click(
968
+ handle_toggle_all,
969
+ [all_done_id, user_id_state, all_filter, all_today_chk],
970
+ [all_df, all_action_msg]
971
+ )
972
+ btn_all_del.click(
973
+ handle_delete_all,
974
+ [all_del_id, user_id_state, all_filter, all_today_chk],
975
+ [all_df, all_action_msg]
976
+ )
977
+
978
+ # ── Journal tab ────────────────────────────────────────────────────────
979
+
980
+ btn_load_tasks.click(
981
+ handle_load_journal_tasks,
982
+ [user_id_state],
983
+ [journal_task_list, journal_mark_row, journal_tasks_state]
984
+ )
985
+
986
+ btn_j_done.click(
987
+ lambda tid, mins, uid, st: handle_journal_mark(tid, mins, uid, st, True),
988
+ [j_task_id, j_actual_min, user_id_state, journal_tasks_state],
989
+ [journal_tasks_state, journal_mark_msg]
990
+ ).then(
991
+ handle_load_journal_tasks,
992
+ [user_id_state],
993
+ [journal_task_list, journal_mark_row, journal_tasks_state]
994
+ )
995
+
996
+ btn_j_undone.click(
997
+ lambda tid, mins, uid, st: handle_journal_mark(tid, mins, uid, st, False),
998
+ [j_task_id, j_actual_min, user_id_state, journal_tasks_state],
999
+ [journal_tasks_state, journal_mark_msg]
1000
+ ).then(
1001
+ handle_load_journal_tasks,
1002
+ [user_id_state],
1003
+ [journal_task_list, journal_mark_row, journal_tasks_state]
1004
+ )
1005
+
1006
+ btn_j_start.click(
1007
+ handle_start_journal,
1008
+ [user_id_state, journal_tasks_state],
1009
+ [journal_chatbot, journal_hist_state, journal_active,
1010
+ j_answer, btn_j_send, journal_msg]
1011
+ )
1012
+
1013
+ btn_j_send.click(
1014
+ handle_send_answer,
1015
+ [user_id_state, j_answer, journal_chatbot, journal_hist_state,
1016
+ journal_tasks_state, journal_active],
1017
+ [journal_chatbot, journal_hist_state, j_answer, journal_msg]
1018
+ )
1019
+
1020
+ j_answer.submit(
1021
+ handle_send_answer,
1022
+ [user_id_state, j_answer, journal_chatbot, journal_hist_state,
1023
+ journal_tasks_state, journal_active],
1024
+ [journal_chatbot, journal_hist_state, j_answer, journal_msg]
1025
+ )
1026
+
1027
+ btn_j_finish.click(
1028
+ handle_finish_journal,
1029
+ [user_id_state, journal_chatbot, journal_hist_state, journal_tasks_state],
1030
+ [journal_chatbot, journal_msg]
1031
+ )
1032
+
1033
+ btn_j_restart.click(
1034
+ handle_restart_journal,
1035
+ outputs=[journal_chatbot, journal_hist_state, journal_active,
1036
+ j_answer, btn_j_send, journal_msg]
1037
+ )
1038
+
1039
+ # ── Areas & Goals tab ─────────────────────────────────────────────────
1040
+
1041
+ btn_add_area.click(
1042
+ handle_add_area,
1043
+ [user_id_state, new_area_name, new_area_color],
1044
+ [area_msg, areas_display, del_area_dd, new_area_name]
1045
+ ).then(
1046
+ lambda uid: gr.update(choices=_area_choices(uid), value="All"),
1047
+ [user_id_state], [all_filter]
1048
+ )
1049
+
1050
+ btn_del_area.click(
1051
+ handle_del_area,
1052
+ [user_id_state, del_area_dd],
1053
+ [del_area_msg, areas_display, del_area_dd]
1054
+ ).then(
1055
+ lambda uid: gr.update(choices=_area_choices(uid), value="All"),
1056
+ [user_id_state], [all_filter]
1057
+ )
1058
+
1059
+ btn_save_goals.click(
1060
+ handle_save_goals,
1061
+ [user_id_state, goals_input],
1062
+ [goals_msg]
1063
+ )
1064
+
1065
+ # ── Preferences tab ───────────────────────────────────────────────────
1066
+
1067
+ btn_save_prefs.click(
1068
+ handle_save_prefs,
1069
+ [user_id_state, pref_wake, pref_sleep, pref_focus, pref_break, pref_flow_max],
1070
+ [prefs_msg]
1071
+ )
1072
+
1073
+ btn_ref_ctx.click(
1074
+ render_context_html,
1075
+ [user_id_state],
1076
+ [ctx_display]
1077
+ )
1078
+
1079
+ # Refresh AI context display whenever the refresh button is clicked
1080
+ # (Gradio Tabs.select doesn't support conditional output, so we use the button)
1081
+
1082
+
1083
+ # ── Launch ─────────────────────────────────────────────────────────────────────
1084
+
1085
+ if __name__ == "__main__":
1086
+ demo.launch(server_name="0.0.0.0", server_port=7860)
database.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/database.py
3
+ SQLite persistence for Second Brain.
4
+ Tables: users, life_areas, goals, tasks, user_context (AI memory)
5
+ """
6
+
7
+ import sqlite3
8
+ import json
9
+ import bcrypt
10
+ from datetime import datetime, date
11
+ from typing import Optional
12
+
13
+ DB_PATH = "second_brain.db"
14
+
15
+
16
+ # ── Connection ────────────────────────────────────────────────────────────────
17
+
18
+ def get_db() -> sqlite3.Connection:
19
+ conn = sqlite3.connect(DB_PATH)
20
+ conn.row_factory = sqlite3.Row
21
+ conn.execute("PRAGMA foreign_keys = ON")
22
+ return conn
23
+
24
+
25
+ def init_db():
26
+ """Create all tables on first run."""
27
+ conn = get_db()
28
+ c = conn.cursor()
29
+
30
+ c.execute("""
31
+ CREATE TABLE IF NOT EXISTS users (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ username TEXT UNIQUE NOT NULL,
34
+ password_hash TEXT NOT NULL,
35
+ created_at TEXT DEFAULT (datetime('now'))
36
+ )
37
+ """)
38
+
39
+ c.execute("""
40
+ CREATE TABLE IF NOT EXISTS life_areas (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ user_id INTEGER NOT NULL,
43
+ name TEXT NOT NULL,
44
+ color TEXT DEFAULT '#6366f1',
45
+ created_at TEXT DEFAULT (datetime('now')),
46
+ FOREIGN KEY (user_id) REFERENCES users(id)
47
+ )
48
+ """)
49
+
50
+ c.execute("""
51
+ CREATE TABLE IF NOT EXISTS user_goals (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ user_id INTEGER NOT NULL,
54
+ goal_text TEXT NOT NULL,
55
+ created_at TEXT DEFAULT (datetime('now')),
56
+ FOREIGN KEY (user_id) REFERENCES users(id)
57
+ )
58
+ """)
59
+
60
+ c.execute("""
61
+ CREATE TABLE IF NOT EXISTS tasks (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ user_id INTEGER NOT NULL,
64
+ title TEXT NOT NULL,
65
+ life_area TEXT DEFAULT '',
66
+ urgency TEXT DEFAULT 'Not Urgent',
67
+ importance TEXT DEFAULT 'Important',
68
+ state_of_mind TEXT DEFAULT 'Easy',
69
+ time_estimate INTEGER DEFAULT 30,
70
+ scheduled_date TEXT DEFAULT (date('now')),
71
+ is_completed INTEGER DEFAULT 0,
72
+ actual_duration INTEGER,
73
+ is_habit INTEGER DEFAULT 0,
74
+ habit_interval TEXT DEFAULT '',
75
+ raw_input TEXT DEFAULT '',
76
+ created_at TEXT DEFAULT (datetime('now')),
77
+ FOREIGN KEY (user_id) REFERENCES users(id)
78
+ )
79
+ """)
80
+
81
+ # AI-learned context stored as a JSON blob per user
82
+ c.execute("""
83
+ CREATE TABLE IF NOT EXISTS user_context (
84
+ user_id INTEGER PRIMARY KEY,
85
+ context TEXT NOT NULL,
86
+ updated_at TEXT DEFAULT (datetime('now')),
87
+ FOREIGN KEY (user_id) REFERENCES users(id)
88
+ )
89
+ """)
90
+
91
+ conn.commit()
92
+ conn.close()
93
+
94
+
95
+ # ── Auth ──────────────────────────────────────────────────────────────────────
96
+
97
+ def register_user(username: str, password: str) -> tuple:
98
+ """Returns (user_id, message). user_id is None on failure."""
99
+ username = username.strip().lower()
100
+ if not username or not password:
101
+ return None, "Username and password cannot be empty."
102
+ if len(password) < 6:
103
+ return None, "Password must be at least 6 characters."
104
+ conn = get_db()
105
+ try:
106
+ pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
107
+ conn.execute(
108
+ "INSERT INTO users (username, password_hash) VALUES (?, ?)",
109
+ (username, pw_hash)
110
+ )
111
+ conn.commit()
112
+ row = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
113
+ return row["id"], "Account created!"
114
+ except sqlite3.IntegrityError:
115
+ return None, "Username already taken."
116
+ finally:
117
+ conn.close()
118
+
119
+
120
+ def login_user(username: str, password: str) -> tuple:
121
+ """Returns (user_id, message). user_id is None on failure."""
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(
127
+ "SELECT id, password_hash FROM users WHERE username = ?", (username,)
128
+ ).fetchone()
129
+ conn.close()
130
+ if not row:
131
+ return None, "Username not found."
132
+ if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
133
+ return None, "Incorrect password."
134
+ return row["id"], f"Welcome back, {username}!"
135
+
136
+
137
+ def get_username(user_id: int) -> str:
138
+ conn = get_db()
139
+ row = conn.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
140
+ conn.close()
141
+ return row["username"].capitalize() if row else "User"
142
+
143
+
144
+ # ── Life Areas ────────────────────────────────────────────────────────────────
145
+
146
+ DEFAULT_AREAS = [
147
+ ("Work", "#4F8EF7"),
148
+ ("Health", "#4CAF87"),
149
+ ("Finance", "#F7A84F"),
150
+ ("Learning", "#A855F7"),
151
+ ("Personal", "#EC4899"),
152
+ ("Family", "#F59E0B"),
153
+ ]
154
+
155
+
156
+ def create_default_life_areas(user_id: int):
157
+ conn = get_db()
158
+ for name, color in DEFAULT_AREAS:
159
+ conn.execute(
160
+ "INSERT INTO life_areas (user_id, name, color) VALUES (?, ?, ?)",
161
+ (user_id, name, color)
162
+ )
163
+ conn.commit()
164
+ conn.close()
165
+
166
+
167
+ def get_life_areas(user_id: int) -> list:
168
+ conn = get_db()
169
+ rows = conn.execute(
170
+ "SELECT id, name, color FROM life_areas WHERE user_id = ? ORDER BY id",
171
+ (user_id,)
172
+ ).fetchall()
173
+ conn.close()
174
+ return [dict(r) for r in rows]
175
+
176
+
177
+ def get_life_area_names(user_id: int) -> list:
178
+ return [a["name"] for a in get_life_areas(user_id)]
179
+
180
+
181
+ def add_life_area(user_id: int, name: str, color: str = "#6366f1") -> tuple:
182
+ name = name.strip()
183
+ if not name:
184
+ return False, "Name cannot be empty."
185
+ conn = get_db()
186
+ exists = conn.execute(
187
+ "SELECT id FROM life_areas WHERE user_id = ? AND LOWER(name) = LOWER(?)",
188
+ (user_id, name)
189
+ ).fetchone()
190
+ if exists:
191
+ conn.close()
192
+ return False, f'"{name}" already exists.'
193
+ conn.execute(
194
+ "INSERT INTO life_areas (user_id, name, color) VALUES (?, ?, ?)",
195
+ (user_id, name, color)
196
+ )
197
+ conn.commit()
198
+ conn.close()
199
+ return True, f'"{name}" added.'
200
+
201
+
202
+ def delete_life_area(user_id: int, name: str) -> tuple:
203
+ conn = get_db()
204
+ conn.execute(
205
+ "DELETE FROM life_areas WHERE user_id = ? AND name = ?", (user_id, name)
206
+ )
207
+ conn.commit()
208
+ conn.close()
209
+ return True, f'"{name}" removed.'
210
+
211
+
212
+ # ── Goals ─────────────────────────────────────────────────────────────────────
213
+
214
+ def save_goals(user_id: int, goals_text: str):
215
+ conn = get_db()
216
+ conn.execute("DELETE FROM user_goals WHERE user_id = ?", (user_id,))
217
+ for line in goals_text.strip().splitlines():
218
+ line = line.strip("β€’- ").strip()
219
+ if line:
220
+ conn.execute(
221
+ "INSERT INTO user_goals (user_id, goal_text) VALUES (?, ?)",
222
+ (user_id, line)
223
+ )
224
+ conn.commit()
225
+ conn.close()
226
+
227
+
228
+ def get_goals(user_id: int) -> list:
229
+ conn = get_db()
230
+ rows = conn.execute(
231
+ "SELECT goal_text FROM user_goals WHERE user_id = ? ORDER BY id",
232
+ (user_id,)
233
+ ).fetchall()
234
+ conn.close()
235
+ return [r["goal_text"] for r in rows]
236
+
237
+
238
+ # ── Tasks ─────────────────────────────────────────────────────────────────────
239
+
240
+ def save_task(user_id: int, task: dict, scheduled_date: str = None) -> int:
241
+ conn = get_db()
242
+ cursor = conn.execute("""
243
+ INSERT INTO tasks
244
+ (user_id, title, life_area, urgency, importance, state_of_mind,
245
+ time_estimate, scheduled_date, raw_input, is_habit, habit_interval)
246
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
247
+ """, (
248
+ user_id,
249
+ task.get("title", "Untitled"),
250
+ task.get("life_area", ""),
251
+ task.get("urgency", "Not Urgent"),
252
+ task.get("importance", "Important"),
253
+ task.get("state_of_mind", "Easy"),
254
+ int(task.get("time_estimate") or 30),
255
+ scheduled_date or str(date.today()),
256
+ task.get("raw_input", ""),
257
+ 1 if task.get("is_habit") else 0,
258
+ task.get("habit_interval", ""),
259
+ ))
260
+ task_id = cursor.lastrowid
261
+ conn.commit()
262
+ conn.close()
263
+ return task_id
264
+
265
+
266
+ def get_tasks(user_id: int, filter_area: str = "All", only_today: bool = False,
267
+ include_completed: bool = True) -> list:
268
+ conn = get_db()
269
+ q = "SELECT * FROM tasks WHERE user_id = ?"
270
+ params = [user_id]
271
+ if filter_area and filter_area != "All":
272
+ q += " AND life_area = ?"
273
+ params.append(filter_area)
274
+ if only_today:
275
+ q += " AND scheduled_date = ?"
276
+ params.append(str(date.today()))
277
+ if not include_completed:
278
+ q += " AND is_completed = 0"
279
+ q += " ORDER BY is_completed ASC, created_at DESC"
280
+ rows = conn.execute(q, params).fetchall()
281
+ conn.close()
282
+ return [dict(r) for r in rows]
283
+
284
+
285
+ def toggle_task_complete(task_id: int, user_id: int, actual_duration: int = None):
286
+ conn = get_db()
287
+ task = conn.execute(
288
+ "SELECT is_completed FROM tasks WHERE id = ? AND user_id = ?", (task_id, user_id)
289
+ ).fetchone()
290
+ if task:
291
+ new_status = 1 - task["is_completed"]
292
+ if actual_duration and new_status == 1:
293
+ conn.execute(
294
+ "UPDATE tasks SET is_completed = ?, actual_duration = ? WHERE id = ? AND user_id = ?",
295
+ (new_status, actual_duration, task_id, user_id)
296
+ )
297
+ else:
298
+ conn.execute(
299
+ "UPDATE tasks SET is_completed = ? WHERE id = ? AND user_id = ?",
300
+ (new_status, task_id, user_id)
301
+ )
302
+ conn.commit()
303
+ conn.close()
304
+
305
+
306
+ def delete_task(task_id: int, user_id: int):
307
+ conn = get_db()
308
+ conn.execute("DELETE FROM tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
309
+ conn.commit()
310
+ conn.close()
311
+
312
+
313
+ def get_today_stats(user_id: int) -> dict:
314
+ tasks = get_tasks(user_id, only_today=True)
315
+ total = len(tasks)
316
+ done = sum(1 for t in tasks if t["is_completed"])
317
+ return {"total": total, "done": done, "remaining": total - done}
318
+
319
+
320
+ # ── Habit recurrence ──────────────────────────────────────────────────────────
321
+
322
+ def spawn_due_habits(user_id: int):
323
+ """
324
+ Check all habit tasks. If a habit's scheduled_date < today and
325
+ it's not already scheduled for today, create a fresh copy for today.
326
+ Called on login / tab load.
327
+ """
328
+ today = str(date.today())
329
+ conn = get_db()
330
+ habits = conn.execute(
331
+ "SELECT * FROM tasks WHERE user_id = ? AND is_habit = 1",
332
+ (user_id,)
333
+ ).fetchall()
334
+
335
+ for h in habits:
336
+ # Check if already exists today
337
+ existing = conn.execute(
338
+ "SELECT id FROM tasks WHERE user_id = ? AND title = ? AND is_habit = 1 AND scheduled_date = ?",
339
+ (user_id, h["title"], today)
340
+ ).fetchone()
341
+ if existing:
342
+ continue
343
+ # Only spawn if original was scheduled before today
344
+ if h["scheduled_date"] and h["scheduled_date"] >= today:
345
+ continue
346
+ conn.execute("""
347
+ INSERT INTO tasks (user_id, title, life_area, urgency, importance,
348
+ state_of_mind, time_estimate, scheduled_date, is_habit,
349
+ habit_interval, raw_input)
350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
351
+ """, (
352
+ user_id, h["title"], h["life_area"], "Habit",
353
+ h["importance"], h["state_of_mind"], h["time_estimate"],
354
+ today, h["habit_interval"], h["raw_input"]
355
+ ))
356
+ conn.commit()
357
+ conn.close()
358
+
359
+
360
+ # ── AI Context ────────────────────────────────────────────────────────────────
361
+
362
+ def load_user_context(user_id: int) -> Optional[dict]:
363
+ conn = get_db()
364
+ row = conn.execute(
365
+ "SELECT context FROM user_context WHERE user_id = ?", (user_id,)
366
+ ).fetchone()
367
+ conn.close()
368
+ if row:
369
+ try:
370
+ return json.loads(row["context"])
371
+ except Exception:
372
+ return None
373
+ return None
374
+
375
+
376
+ def save_user_context(user_id: int, context: dict):
377
+ conn = get_db()
378
+ conn.execute("""
379
+ INSERT INTO user_context (user_id, context, updated_at)
380
+ VALUES (?, ?, datetime('now'))
381
+ ON CONFLICT(user_id) DO UPDATE SET
382
+ context = excluded.context,
383
+ updated_at = excluded.updated_at
384
+ """, (user_id, json.dumps(context)))
385
+ conn.commit()
386
+ conn.close()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.20.0
2
+ groq>=0.4.0
3
+ openai-whisper>=20231117
4
+ bcrypt>=4.0.0
5
+ python-multipart>=0.0.9
styles.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/styles.py
3
+ All CSS for the Second Brain Gradio app.
4
+ """
5
+
6
+ CSS = """
7
+ @import url('https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
8
+
9
+ /* ── Reset & base ──────────────────────────────────────── */
10
+ *, *::before, *::after { box-sizing: border-box; }
11
+
12
+ body, .gradio-container {
13
+ font-family: 'Sora', system-ui, sans-serif !important;
14
+ background: #060a12 !important;
15
+ color: #cbd5e1 !important;
16
+ }
17
+ .gradio-container { max-width: 1200px !important; margin: 0 auto !important; }
18
+ .main { padding: 16px !important; }
19
+
20
+ /* ── Auth card ─────────────────────────────────────────── */
21
+ #auth-card {
22
+ max-width: 420px;
23
+ margin: 48px auto 0;
24
+ background: #0d1117;
25
+ border: 1px solid #1e293b;
26
+ border-radius: 20px;
27
+ padding: 40px 36px 32px;
28
+ box-shadow: 0 0 80px rgba(99,102,241,.1);
29
+ }
30
+ #brand-logo {
31
+ font-size: 28px; font-weight: 700; text-align: center;
32
+ background: linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%);
33
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
34
+ margin-bottom: 4px;
35
+ }
36
+ #brand-sub { text-align: center; color: #334155; font-size: 13px; margin-bottom: 28px; }
37
+
38
+ /* ── Top header ────────────────────────────────────────── */
39
+ #top-header {
40
+ background: #0d1117;
41
+ border: 1px solid #1e293b;
42
+ border-radius: 14px;
43
+ padding: 14px 22px;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ margin-bottom: 18px;
48
+ }
49
+ #top-header-brand {
50
+ font-size: 18px; font-weight: 700;
51
+ background: linear-gradient(135deg, #a78bfa, #60a5fa);
52
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
53
+ }
54
+ #top-header-user { font-size: 12px; color: #334155; }
55
+
56
+ /* ── Stat cards ────────────────────────────────────────── */
57
+ .stat-card {
58
+ background: #0d1117;
59
+ border: 1px solid #1e293b;
60
+ border-radius: 14px;
61
+ padding: 18px 12px;
62
+ text-align: center;
63
+ min-height: 90px;
64
+ }
65
+ .stat-num { font-size: 36px; font-weight: 700; line-height: 1; }
66
+ .stat-label { font-size: 10px; color: #334155; margin-top: 6px;
67
+ text-transform: uppercase; letter-spacing: .8px; }
68
+
69
+ /* ── Panels ────────────────────────────────────────────── */
70
+ .panel {
71
+ background: #0d1117;
72
+ border: 1px solid #1e293b;
73
+ border-radius: 14px;
74
+ padding: 22px 20px;
75
+ margin-top: 14px;
76
+ }
77
+ .sec-label {
78
+ font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
79
+ color: #334155; font-weight: 600; margin-bottom: 16px;
80
+ }
81
+
82
+ /* ── Buttons ───────────────────────────────────────────── */
83
+ .btn-primary button {
84
+ background: linear-gradient(135deg,#6366f1,#8b5cf6) !important;
85
+ color: #fff !important; font-weight: 600 !important;
86
+ border: none !important; border-radius: 10px !important;
87
+ font-family: 'Sora', sans-serif !important;
88
+ transition: opacity .15s !important;
89
+ }
90
+ .btn-primary button:hover { opacity: .85 !important; }
91
+
92
+ .btn-accent button {
93
+ background: linear-gradient(135deg,#0ea5e9,#6366f1) !important;
94
+ color: #fff !important; font-weight: 600 !important;
95
+ border: none !important; border-radius: 10px !important;
96
+ font-family: 'Sora', sans-serif !important;
97
+ }
98
+ .btn-secondary button {
99
+ background: #161b27 !important; color: #64748b !important;
100
+ border: 1px solid #1e293b !important; border-radius: 10px !important;
101
+ font-family: 'Sora', sans-serif !important;
102
+ }
103
+ .btn-success button {
104
+ background: #052e16 !important; color: #4ade80 !important;
105
+ border: 1px solid #14532d !important; border-radius: 10px !important;
106
+ font-family: 'Sora', sans-serif !important;
107
+ }
108
+ .btn-danger button {
109
+ background: #1c0606 !important; color: #f87171 !important;
110
+ border: 1px solid #450a0a !important; border-radius: 10px !important;
111
+ font-family: 'Sora', sans-serif !important;
112
+ }
113
+
114
+ /* ── Inputs ────────────────────────────────────────────── */
115
+ input, textarea, select {
116
+ background: #060a12 !important;
117
+ border: 1px solid #1e293b !important;
118
+ border-radius: 10px !important;
119
+ color: #e2e8f0 !important;
120
+ font-family: 'Sora', sans-serif !important;
121
+ }
122
+ input:focus, textarea:focus {
123
+ border-color: #6366f1 !important;
124
+ box-shadow: 0 0 0 2px rgba(99,102,241,.18) !important;
125
+ }
126
+ label { color: #475569 !important; font-size: 12px !important; font-weight: 500 !important; }
127
+
128
+ /* ── Tabs ──────────────────────────────────────────────── */
129
+ .tab-nav button {
130
+ color: #334155 !important; font-family: 'Sora', sans-serif !important;
131
+ font-weight: 500 !important; background: transparent !important;
132
+ border: none !important; border-bottom: 2px solid transparent !important;
133
+ padding: 10px 18px !important; font-size: 13px !important;
134
+ }
135
+ .tab-nav button.selected {
136
+ color: #a78bfa !important;
137
+ border-bottom: 2px solid #a78bfa !important;
138
+ }
139
+
140
+ /* ── Dataframe ─────────────────────────────────────────── */
141
+ .dataframe thead th {
142
+ background: #0d1117 !important; color: #334155 !important;
143
+ font-size: 10px !important; text-transform: uppercase !important;
144
+ letter-spacing: .6px !important; padding: 10px 12px !important;
145
+ font-family: 'Sora', sans-serif !important; border: none !important;
146
+ }
147
+ .dataframe tbody td {
148
+ background: #060a12 !important; color: #94a3b8 !important;
149
+ font-family: 'JetBrains Mono', monospace !important;
150
+ font-size: 12px !important; padding: 9px 12px !important;
151
+ border-bottom: 1px solid #0d1117 !important;
152
+ }
153
+ .dataframe tbody tr:hover td { background: #0d1117 !important; }
154
+
155
+ /* ── Chatbot ───────────────────────────────────────────── */
156
+ .chatbot {
157
+ background: #060a12 !important;
158
+ border: 1px solid #1e293b !important;
159
+ border-radius: 14px !important;
160
+ }
161
+
162
+ /* ── Schedule cards ────────────────────────────────────── */
163
+ .sched-card {
164
+ background: #0d1117;
165
+ border: 1px solid #1e293b;
166
+ border-left: 3px solid #6366f1;
167
+ border-radius: 10px;
168
+ padding: 12px 16px;
169
+ margin-bottom: 8px;
170
+ }
171
+ .sched-time { font-size: 11px; font-family: 'JetBrains Mono',monospace; color: #6366f1; font-weight: 600; }
172
+ .sched-title { font-size: 14px; font-weight: 600; color: #f1f5f9; margin: 2px 0 4px; }
173
+ .sched-meta { font-size: 11px; color: #334155; }
174
+ .sched-why { font-size: 11px; color: #475569; font-style: italic; margin-top: 3px; }
175
+
176
+ /* ── Life area chips ───────────────────────────────────── */
177
+ .chip {
178
+ display: inline-block; padding: 4px 12px; border-radius: 20px;
179
+ font-size: 12px; font-weight: 600; margin: 3px;
180
+ }
181
+
182
+ /* ── Status messages ───────────────────────────────────── */
183
+ .ok { color: #4ade80; font-size: 13px; }
184
+ .err { color: #f87171; font-size: 13px; }
185
+ .inf { color: #60a5fa; font-size: 13px; }
186
+
187
+ /* ── Misc ──────────────────────────────────────────────── */
188
+ hr { border-color: #1e293b !important; margin: 16px 0 !important; }
189
+ .hidden { display: none !important; }
190
+ """