Compare commits

...

49 Commits

Author SHA1 Message Date
bvandeusen 8fb81bc1ed Merge pull request 'Release v26.04.10.1' (#28) from dev into main 2026-04-10 15:05:15 +00:00
bvandeusen c1bc73da8e fix(briefing): remove redundant current-conditions block; patch live temp into WeatherCard
The standalone current-conditions div showed just a bare temperature with no
forecast context, making the weather panel look incomplete. Now the live
temperature from /weather/current is patched directly into the WeatherCard's
current_temp so it stays fresh without a second display block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:43:30 -04:00
bvandeusen 3b8d40fea3 feat: overall completion bar on project cards; auto-collapse done milestones
ProjectListView: add an overall task completion bar above the per-milestone
bars showing the percentage of all project tasks that are done.

ProjectView: milestones where every task is complete now start collapsed
by default, keeping the view clean for projects with many finished stages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:38:02 -04:00
bvandeusen 2f5abc034e style: update email templates to Modern Fable visual identity
Replace indigo (#6366f1) with deep violet (#7c3aed) gradient header,
violet-tinted card border and background, refined border radius and
spacing, and violet-accented footer to match the app's design language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:31:16 -04:00
bvandeusen 7a01733334 fix: coerce null project fields to empty string to avoid NOT NULL violation 2026-04-09 21:34:14 -04:00
bvandeusen 7d5611d00b fix: add 'paused' tab to project list filter 2026-04-09 19:33:50 -04:00
bvandeusen e5821ef3f8 fix: allow 'paused' in project status validation 2026-04-09 18:17:48 -04:00
bvandeusen 43231f44d2 fix(search): hybrid keyword + semantic search — exact title/body matches rank first 2026-04-09 12:28:55 -04:00
bvandeusen ab0b9c3199 fix(lint): add timezone import, use timedelta directly, remove unused variable 2026-04-09 08:46:34 -04:00
bvandeusen d12fc5d282 feat: paused project status with auto-pause (14 days), auto-reactivate on activity; fix back button context 2026-04-09 08:37:08 -04:00
bvandeusen 4c3d67994b fix: back button returns to project when note/task has a project, otherwise to Knowledge 2026-04-09 08:18:46 -04:00
bvandeusen 126bad7d99 fix(header): darken nav text in light mode — use text-secondary for inactive, primary-solid for active 2026-04-08 23:43:25 -04:00
bvandeusen 28fae75258 Merge pull request 'Release v26.04.09.1 — Briefing intelligence overhaul' (#27) from dev into main 2026-04-09 03:30:17 +00:00
bvandeusen 058bd76719 feat(briefing): multi-article topic discussion endpoint for deep thematic analysis 2026-04-08 22:53:23 -04:00
bvandeusen d56b57ee40 feat(briefing): cluster-level preference filtering — rank themes by user interest, suppress disliked topics 2026-04-08 22:51:13 -04:00
bvandeusen 8762552234 feat(briefing): project continuity — yesterday's activity and stale project warnings 2026-04-08 22:19:38 -04:00
bvandeusen 6cbf9be052 feat(briefing): weekly review — 7-day recap with task/note/project summary and upcoming preview 2026-04-08 22:18:19 -04:00
bvandeusen 3687fbeeb9 feat(briefing): differentiated check-in slots — midday progress nudge, afternoon wrap-up 2026-04-08 22:09:18 -04:00
bvandeusen a6543c1dc5 feat(briefing): cluster news by topic for thematic synthesis instead of flat headlines 2026-04-08 22:07:24 -04:00
bvandeusen 4558dd578a feat(briefing): weather × calendar cross-reference — rain warnings for outdoor events 2026-04-08 22:03:18 -04:00
bvandeusen 8647f52fbc feat(weather): live current conditions endpoint + polling display in briefing view 2026-04-08 21:33:27 -04:00
bvandeusen 304affb837 feat(briefing): daily planning — prioritized focus list and calendar gap analysis 2026-04-08 20:59:06 -04:00
bvandeusen 2e3f90384d feat(briefing): actionable intelligence — days overdue, project context, suggest next steps 2026-04-08 20:33:16 -04:00
bvandeusen 16b9ed9392 Merge pull request 'Release v26.04.08.3 — Type editors, calendar UX, DRY theme, contrast' (#26) from dev into main 2026-04-08 18:52:25 +00:00
bvandeusen f9c6802939 feat(calendar): linked start/end times, 1h default duration, smart rounding, past event hint 2026-04-08 13:47:15 -04:00
bvandeusen f2debd8a5b feat(knowledge): show organization for person cards, category for place cards
Exposes birthday, organization, address (person) and website, category (place)
from entity_meta in the knowledge API response; updates KnowledgeView cards
to display organization and category as visible meta chips/text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:32:15 -04:00
bvandeusen f8f14eea0f feat(editor): place form-first layout and list builder with Enter-to-add
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:30:26 -04:00
bvandeusen 64c19932a7 feat(editor): person form-first layout with structured fields and collapsible notes
When note type is 'person', replace the main TipTap editor with a contact card form
(Relationship, Birthday, Email, Phone, Organization, Address). The TipTap editor moves
to a collapsible 'Notes' section below the form, auto-expanded when existing body content
is present. Person fields are removed from the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:27:22 -04:00
bvandeusen 317d8f25d0 feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:07 -04:00
bvandeusen 87f74b1cd5 docs: add specialized note type editors implementation plan 2026-04-08 13:21:20 -04:00
bvandeusen f7fda0adca docs: add specialized note type editors design spec 2026-04-08 13:08:01 -04:00
bvandeusen 0ca39a2e34 feat(knowledge): redesign new item button as gradient CTA with icon menu for all 5 types 2026-04-08 12:34:52 -04:00
bvandeusen 16d9af8b96 fix(theme): increase dark mode contrast for borders, glows, and card edges; DRY color values into CSS variables 2026-04-08 12:22:03 -04:00
bvandeusen 2ac894b5d1 refactor(theme): DRY hardcoded violet values into CSS custom properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:19:38 -04:00
bvandeusen 7e81e50e3e feat(theme): sweep indigo to violet across all remaining views and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:12:46 -04:00
bvandeusen d8945536c4 Merge pull request 'Release v26.04.08.2 — Knowledge consolidation + Modern Fable identity' (#25) from dev into main 2026-04-08 15:40:12 +00:00
bvandeusen f30e90ef8d feat: narrator empty states, scroll fades, glow buttons, violet color sweep 2026-04-08 11:17:06 -04:00
bvandeusen 00f82f8cba feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:14:31 -04:00
bvandeusen f953a36b86 feat(header): pill nav bar, brand shortening, status pulse, header gradient 2026-04-08 11:11:35 -04:00
bvandeusen 4bc7f9eaac feat(theme): shift palette from indigo to deep violet + muted gold
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:09:23 -04:00
bvandeusen 670de547ad docs: add Modern Fable visual identity implementation plan 2026-04-08 10:43:20 -04:00
bvandeusen a684d84edf feat: deprecate /notes and /tasks routes; redirect to Knowledge view 2026-04-08 10:35:21 -04:00
bvandeusen 34729cf1cf feat(knowledge): add task cards with status/priority/due-date display 2026-04-08 10:32:51 -04:00
bvandeusen dd16b39218 feat(knowledge): include tasks in knowledge queries and counts 2026-04-08 10:27:31 -04:00
bvandeusen 0bd362f582 docs: add Knowledge task consolidation implementation plan 2026-04-08 10:11:25 -04:00
bvandeusen 0afa2d8771 docs: add Modern Fable visual identity design spec 2026-04-08 10:04:55 -04:00
bvandeusen 93af4e6bab docs: add Knowledge view task consolidation design spec 2026-04-08 09:47:11 -04:00
bvandeusen 6e3de91a6f Merge pull request 'fix: resume AudioContext for silence detection' (#24) from dev into main 2026-04-08 12:37:16 +00:00
bvandeusen 6e0c528126 fix(voice): resume AudioContext to prevent suspended state blocking silence detection 2026-04-07 22:54:41 -04:00
46 changed files with 4784 additions and 1432 deletions
@@ -0,0 +1,746 @@
# Knowledge View Task Consolidation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Consolidate tasks into the Knowledge view as a fifth card type, deprecate `/notes` and `/tasks` list routes, and simplify navigation down to a single Knowledge hub.
**Architecture:** The backend knowledge service (`services/knowledge.py`) stops excluding tasks from queries and adds `type=task` filtering via the `is_task` property (`Note.status IS NOT NULL`). The knowledge route validation gains `"task"` as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views.
**Tech Stack:** Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `src/fabledassistant/services/knowledge.py` |
| Modify | `src/fabledassistant/routes/knowledge.py` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `frontend/src/router/index.ts` |
| Modify | `frontend/src/components/AppHeader.vue` |
| Modify | `frontend/src/App.vue` |
| Delete | `frontend/src/views/NotesListView.vue` |
| Delete | `frontend/src/views/TasksListView.vue` |
---
### Task 1: Backend — Include tasks in knowledge queries
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `src/fabledassistant/routes/knowledge.py`
**Context:** The knowledge service currently excludes tasks by filtering `Note.status.is_(None)`. Every query function (`query_knowledge`, `query_knowledge_ids`, `_semantic_knowledge_search`, `get_knowledge_tags`, `get_knowledge_counts`) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding `type=task` as a filter option that maps to `Note.status.isnot(None)`, (3) enriching `_note_to_item` with task-specific fields, (4) updating counts to include tasks.
- [ ] **Step 1: Add `"task"` to `_VALID_TYPES` in the route file**
In `src/fabledassistant/routes/knowledge.py`, change:
```python
_VALID_TYPES = {"note", "person", "place", "list"}
```
to:
```python
_VALID_TYPES = {"note", "person", "place", "list", "task"}
```
- [ ] **Step 2: Update `_note_to_item` to include task fields**
In `src/fabledassistant/services/knowledge.py`, the `_note_to_item` function builds the item dict. After the existing `elif note.entity_type == "list":` block (which ends around line 48), add a task branch. Find:
```python
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
return item
```
Replace with:
```python
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
# Task fields — included for all items but only meaningful when is_task
if note.is_task:
item["note_type"] = "task"
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
return item
```
This overrides `note_type` to `"task"` for task items (since `entity_type` returns the `note_type` column which is `"note"` for tasks) and adds status/priority/due_date fields.
- [ ] **Step 3: Update `query_knowledge` to include tasks**
In the `query_knowledge` function, the "all types" filter currently excludes tasks. Change the base query and the `else` branch.
Find:
```python
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.status.is_(None)) # exclude tasks
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
# Exclude tasks — already done above; also exclude any legacy nulls
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = select(Note).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
# All types including tasks
pass
```
- [ ] **Step 4: Update `_semantic_knowledge_search` to include tasks**
Find:
```python
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=False,
)
```
Replace with:
```python
is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=is_task_filter,
)
```
Also update the type matching in the filter loop — find:
```python
for _score, note in candidates:
if note_type and note.entity_type != note_type:
continue
```
Replace with:
```python
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
```
- [ ] **Step 5: Update `query_knowledge_ids` to include tasks**
Find:
```python
base = (
select(Note.id)
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = select(Note.id).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
pass
```
- [ ] **Step 6: Update `get_knowledge_tags` to include task tags**
Find:
```python
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
pass
```
- [ ] **Step 7: Update `get_knowledge_counts` to include tasks**
Find:
```python
async with async_session() as session:
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Ensure all types present even if zero
for t in ("note", "person", "place", "list"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
return counts
```
Replace with:
```python
async with async_session() as session:
# Count non-task types
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Count tasks separately (is_task = status IS NOT NULL)
task_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
)
if tags:
for tag in tags:
task_stmt = task_stmt.where(Note.tags.contains([tag]))
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
for t in ("note", "person", "place", "list", "task"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
return counts
```
- [ ] **Step 8: Verify backend changes**
```bash
cd /path/to/fabledassistant
make typecheck
make test
```
Expected: no errors.
- [ ] **Step 9: Commit**
```bash
git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py
git commit -m "feat(knowledge): include tasks in knowledge queries and counts"
```
---
### Task 2: Frontend — Task card rendering in KnowledgeView
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** `KnowledgeView.vue` has a `KnowledgeItem` interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The `activeType` ref controls filtering; it needs `"task"` as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle.
- [ ] **Step 1: Add `"task"` to the KnowledgeItem interface and filter type**
In the `<script setup>` section, find the `KnowledgeItem` interface and add task fields:
```ts
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list";
// ... existing fields
```
Change to:
```ts
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list" | "task";
// ... existing fields
```
Also add the task-specific fields at the end of the interface (before the closing `}`):
```ts
// Task-specific
status?: string;
priority?: string;
due_date?: string;
```
Update the `activeType` ref type:
```ts
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
```
Change to:
```ts
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
```
- [ ] **Step 2: Add "Tasks" to the type filter sidebar**
Find the type filter `v-for` in the template:
```html
<button
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
```
Replace with:
```html
<button
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
```
Update the type cast on the click handler. Find:
```html
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
```
Replace with:
```html
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
```
- [ ] **Step 3: Add task card content in the template**
In the card grid, find the note snippet section:
```html
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
```
Add a task-specific section above it:
```html
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
```
- [ ] **Step 4: Add `isOverdue` helper and update `openItem` for tasks**
In the `<script setup>`, add after the `formatDate` function:
```ts
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
```
Update `openItem` to route tasks to their editor:
```ts
function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
}
```
- [ ] **Step 5: Update the "New note" button to toggle interaction**
Find the current new-note button markup:
```html
<div class="new-note-wrap">
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">Note</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
</div>
</div>
```
Replace with:
```html
<div class="new-note-wrap">
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('task')">Task</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
</div>
</div>
```
Add a click-outside handler. In the `<script setup>`, add after the `createNew` function:
```ts
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
}
```
In `onMounted`, add:
```ts
document.addEventListener('click', onClickOutsideNewNote);
```
In `onUnmounted`, add:
```ts
document.removeEventListener('click', onClickOutsideNewNote);
```
- [ ] **Step 6: Add task card CSS**
Append to the `<style scoped>` block:
```css
/* ── Task card ──────────────────────────────────────────── */
.k-card--task { border-left: 3px solid #a78bfa; }
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.task-overdue {
color: var(--color-overdue);
font-weight: 500;
}
```
Also add the task type badge color. Find:
```css
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
```
Add after it:
```css
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
```
- [ ] **Step 7: Remove the chevron button CSS**
Find and delete:
```css
.btn-new-chevron {
padding: 7px 9px;
border-radius: 0 8px 8px 0;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.78rem;
line-height: 1;
transition: background 0.15s, transform 0.15s;
}
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }
```
Update `.btn-new-note` to have full border-radius now that the chevron is gone:
```css
.btn-new-note {
flex: 1;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
transition: background 0.15s;
}
```
- [ ] **Step 8: Verify TypeScript compiles**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no new errors (pre-existing TipTap errors are fine).
- [ ] **Step 9: Commit**
```bash
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): add task cards with status/priority/due-date display"
```
---
### Task 3: Route redirects, navigation cleanup, dead code removal
**Files:**
- Modify: `frontend/src/router/index.ts`
- Modify: `frontend/src/components/AppHeader.vue`
- Modify: `frontend/src/App.vue`
- Delete: `frontend/src/views/NotesListView.vue`
- Delete: `frontend/src/views/TasksListView.vue`
**Context:** The router currently has `/notes` and `/tasks` pointing to list view components. These become redirects to `/`. The AppHeader has "Tasks" in both desktop and mobile nav. The `g+t` keyboard shortcut navigates to `/tasks` which should change to `/`. The stores (`notes.ts`, `tasks.ts`) are used by other views so they stay.
- [ ] **Step 1: Replace list view routes with redirects**
In `frontend/src/router/index.ts`, find:
```ts
{
path: "/notes",
name: "notes",
component: () => import("@/views/NotesListView.vue"),
},
```
Replace with:
```ts
{
path: "/notes",
redirect: "/",
},
```
Find:
```ts
{
path: "/tasks",
name: "tasks",
component: () => import("@/views/TasksListView.vue"),
},
```
Replace with:
```ts
{
path: "/tasks",
redirect: "/",
},
```
- [ ] **Step 2: Remove "Tasks" from AppHeader navigation**
In `frontend/src/components/AppHeader.vue`, find in the desktop nav-center:
```html
<router-link to="/tasks" class="nav-link">Tasks</router-link>
```
Delete this line.
Find in the mobile dropdown menu:
```html
<router-link to="/tasks" class="nav-link">Tasks</router-link>
```
Delete this line.
- [ ] **Step 3: Update `g+t` keyboard shortcut in App.vue**
In `frontend/src/App.vue`, find in the `onGlobalKeydown` function, inside the `if (pendingPrefix === "g")` block:
```ts
case "t": router.push("/tasks"); break;
```
Replace with:
```ts
case "t": router.push("/"); break;
```
- [ ] **Step 4: Update shortcuts overlay text**
In `frontend/src/App.vue`, find in the shortcuts overlay template:
```html
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Tasks</span>
```
Replace the description:
```html
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Knowledge (tasks)</span>
```
- [ ] **Step 5: Delete the deprecated list view files**
```bash
rm frontend/src/views/NotesListView.vue
rm frontend/src/views/TasksListView.vue
```
- [ ] **Step 6: Verify build**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no new errors. The deleted files were only imported via lazy `() => import(...)` in the router, which we already replaced with redirects.
- [ ] **Step 7: Commit**
```bash
git add -A frontend/src/views/NotesListView.vue frontend/src/views/TasksListView.vue \
frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: deprecate /notes and /tasks routes; redirect to Knowledge view"
```
@@ -0,0 +1,786 @@
# Specialized Note Type Editors — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/views/NoteEditorView.vue` |
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `src/fabledassistant/services/knowledge.py` |
---
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
**Files:**
- Modify: `frontend/src/components/MarkdownToolbar.vue`
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
In `frontend/src/components/MarkdownToolbar.vue`, find:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
@mousedown.prevent="btn.command()"
>
```
Replace with:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
tabindex="-1"
@mousedown.prevent="btn.command()"
>
```
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
In `frontend/src/views/NoteEditorView.vue`, find the title input:
```html
<input
ref="titleRef"
v-model="title"
type="text"
placeholder="Title"
class="title-input"
```
Replace with:
```html
<input
ref="titleRef"
v-model="title"
type="text"
:placeholder="titlePlaceholder"
class="title-input"
```
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
```ts
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
```
- [ ] **Step 3: Auto-focus title on mount**
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
```ts
await nextTick();
titleRef.value?.focus();
```
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
```
---
### Task 2: Person editor — form-first layout
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
- [ ] **Step 1: Add the person form template**
In the template, find the `<!-- ── Main column ──` section. The current structure is:
```html
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row">
...
</div>
<!-- Streaming/Review/Normal editor templates -->
</div>
```
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
```html
<div class="note-main">
<!-- ── Person form ──────────────────────────────────────── -->
<template v-if="noteType === 'person'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- ── Generic note editor (existing) ───────────────────── -->
<template v-else-if="noteType === 'note'">
<!-- ... existing TipTap-first editor content stays here ... -->
</template>
</div>
```
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
- [ ] **Step 2: Add `notesExpanded` ref**
In the `<script setup>`, after the `sidebarOpen` ref, add:
```ts
const notesExpanded = ref(false);
```
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
```ts
notesExpanded.value = !!(store.currentNote.body || '').trim();
```
- [ ] **Step 3: Remove person fields from sidebar**
In the sidebar template, find:
```html
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
```
Delete this entire block.
- [ ] **Step 4: Add entity form CSS**
Add to the `<style scoped>` block:
```css
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
```
- [ ] **Step 5: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
```
---
### Task 3: Place editor + List builder
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
- [ ] **Step 1: Add place form template**
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── Place form ───────────────────────────────────────── -->
<template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 2: Remove place fields from sidebar**
Find and delete:
```html
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
```
- [ ] **Step 3: Add list item types and state**
In the `<script setup>`, after the `notesExpanded` ref, add:
```ts
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
```
- [ ] **Step 4: Initialize list items on mount**
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
```ts
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
```
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
```ts
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
```
- [ ] **Step 5: Update save to serialize list**
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
```ts
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
```
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
- [ ] **Step 6: Add list builder template**
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── List builder ─────────────────────────────────────── -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 7: Add list builder CSS**
Add to the `<style scoped>` block:
```css
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
```
- [ ] **Step 8: Handle the generic note template wrapper**
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
The final structure in `.note-main` should be:
```
<template v-if="noteType === 'person'"> ... </template>
<template v-else-if="noteType === 'place'"> ... </template>
<template v-else-if="noteType === 'list'"> ... </template>
<template v-else> ... existing TipTap editor ... </template>
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
```
---
### Task 4: Backend — new person/place fields in knowledge cards
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
- [ ] **Step 1: Update `_note_to_item` for person**
In `src/fabledassistant/services/knowledge.py`, find:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
```
Replace with:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
```
- [ ] **Step 2: Update `_note_to_item` for place**
Find:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
```
Replace with:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
```
- [ ] **Step 3: Update KnowledgeItem interface**
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
After `phone?: string;` add:
```ts
birthday?: string;
organization?: string;
```
After `hours?: string;` add:
```ts
website?: string;
category?: string;
```
- [ ] **Step 4: Update person card display**
In the template, find the person card specifics:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
Replace with:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
- [ ] **Step 5: Update place card display**
Find:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
Replace with:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
```bash
cd frontend && npx tsc --noEmit
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
```
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
```
@@ -0,0 +1,785 @@
# Modern Fable Visual Identity — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic indigo dark-mode palette with a distinctive "Modern Fable" visual identity — deep violet + muted gold, signature card types, pill nav, Fraunces-as-narrator typography, and living micro-details.
**Architecture:** Pure frontend changes across theme CSS, AppHeader, AppLogo, KnowledgeView, ChatPanel, BriefingView, and CalendarView. No backend changes. Each task is independently deployable — palette first, then cards, then nav, then typography, then details.
**Tech Stack:** Vue 3 SFC (scoped CSS), CSS custom properties, Fraunces font (already loaded).
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/assets/theme.css` |
| Modify | `frontend/src/components/AppLogo.vue` |
| Modify | `frontend/src/components/AppHeader.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `frontend/src/components/ChatPanel.vue` |
| Modify | `frontend/src/views/BriefingView.vue` |
| Modify | `frontend/src/views/CalendarView.vue` |
| Modify | `frontend/src/App.vue` |
---
### Task 1: Color palette update + logo + scrollbar
**Files:**
- Modify: `frontend/src/assets/theme.css`
- Modify: `frontend/src/components/AppLogo.vue`
- [ ] **Step 1: Update the dark theme palette in theme.css**
In `frontend/src/assets/theme.css`, find the `[data-theme="dark"]` block and replace these values:
```css
[data-theme="dark"] {
--color-bg: #0f0f14;
--color-bg-secondary: #16161f;
--color-bg-card: #1a1a24;
--color-surface: #16161f;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(124, 58, 237, 0.12);
--color-input-border: rgba(124, 58, 237, 0.22);
--color-primary: #a78bfa;
--color-danger: #f44336;
--color-tag-bg: #2a2a45;
--color-tag-text: #c4b5fd;
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2a2a35;
--color-status-in-progress: #a78bfa;
--color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
--color-priority-low-bg: #1a3a38;
--color-priority-medium: #fdd835;
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45;
--color-overdue: #f44336;
--color-code-bg: #12121a;
--color-code-inline-bg: #1a1a2a;
--color-table-stripe: #14141e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1a1a24;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017;
--color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
}
```
Note: `--color-accent-warm`, `--color-accent-warm-light`, `--color-primary-solid`, and `--color-primary-deep` are new variables.
- [ ] **Step 2: Update the light theme palette**
In the `:root` block, update these values:
```css
:root {
--color-bg: #f5f5fb;
--color-bg-secondary: #ededf5;
--color-bg-card: #ffffff;
--color-surface: #f0f0f8;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #dddde8;
--color-input-border: #c8c8d8;
--color-primary: #7c3aed;
--color-danger: #d93025;
--color-tag-bg: #ede5ff;
--color-tag-text: #6d28d9;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #7c3aed;
--color-status-in-progress-bg: #ede5ff;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
--color-priority-low-bg: #e0f2f1;
--color-priority-medium: #f9a825;
--color-priority-medium-bg: #fff8e1;
--color-priority-high: #d93025;
--color-priority-high-bg: #fce8e6;
--color-wikilink: #7b1fa2;
--color-wikilink-bg: #f3e5f5;
--color-overdue: #d93025;
--color-code-bg: #f0f0f8;
--color-code-inline-bg: #eaeaf4;
--color-table-stripe: #f4f4fb;
--color-success: #22c55e;
--color-warning: #eab308;
--color-input-bar-bg: #eaeaf3;
--color-input-bar-text: #1a1a1a;
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
/* New brand variables */
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
}
```
- [ ] **Step 3: Update scrollbar color**
Find:
```css
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45);
}
```
Replace with:
```css
::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.25);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.45);
}
```
- [ ] **Step 4: Update focus ring**
Find:
```css
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
```
Replace with:
```css
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
```
- [ ] **Step 5: Update AppLogo gradient**
In `frontend/src/components/AppLogo.vue`, the logo uses `var(--color-primary)` which will automatically pick up the new violet value. No code change needed — the CSS variable update handles it.
However, add a gradient `<defs>` for the book fill to use the deep gradient instead of a flat color. Find the `<style scoped>` block:
```css
.logo-book {
fill: var(--color-primary);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
```
Replace with:
```css
.logo-book {
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
```
And add a gradient definition inside the `<svg>` element, before the `<!-- Book body -->` comment:
```html
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/assets/theme.css frontend/src/components/AppLogo.vue
git commit -m "feat(theme): shift palette from indigo to deep violet + muted gold"
```
---
### Task 2: Signature header — pill nav + brand shortening + status pulse
**Files:**
- Modify: `frontend/src/components/AppHeader.vue`
- [ ] **Step 1: Update brand text in header**
Find:
```html
Fabled Assistant
```
Replace with:
```html
<span class="brand-text">Fabled</span>
```
- [ ] **Step 2: Wrap nav-center links in a pill container**
Find the `nav-center` div:
```html
<div class="nav-center">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
```
Replace with:
```html
<div class="nav-center">
<div class="nav-pill-bar">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div>
```
- [ ] **Step 3: Replace header and nav CSS**
Replace the entire `<style scoped>` from `.app-header` through `.nav-link.router-link-active` with:
```css
.app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
position: relative;
}
.nav {
padding: 0.6rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
/* Left — brand */
.nav-brand {
display: flex;
align-items: center;
gap: 0.45rem;
text-decoration: none;
flex-shrink: 0;
}
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
}
/* Center — pill bar */
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
}
.nav-pill-bar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(124, 58, 237, 0.06);
border-radius: 10px;
padding: 3px;
}
/* Right */
.nav-right {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.nav-link {
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
border-radius: 8px;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-text-secondary);
background: rgba(124, 58, 237, 0.08);
}
.nav-link.router-link-active {
color: #c4b5fd;
font-weight: 600;
background: rgba(124, 58, 237, 0.2);
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
}
```
- [ ] **Step 4: Add status dot pulse animation for loaded state**
Find:
```css
.status-green .status-dot { background: var(--color-success, #2ecc71); }
```
Replace with:
```css
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
```
Find:
```css
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
```
Add after it:
```css
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
```
- [ ] **Step 5: Update mobile menu active styling**
Find:
```css
.mobile-menu .nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
}
```
Replace with:
```css
.mobile-menu .nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: rgba(124, 58, 237, 0.15);
box-shadow: none;
}
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/AppHeader.vue
git commit -m "feat(header): pill nav bar, brand shortening, status pulse, header gradient"
```
---
### Task 3: Card type DNA — gradient bars, corner accents, hover bloom
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The cards currently have a left accent strip per type. The new design replaces this with top gradient bars (notes, tasks, lists) and corner accents (person, place), plus a unified violet hover bloom.
- [ ] **Step 1: Replace card accent strips with type-specific top bars and borders**
Find in the `<style scoped>`:
```css
/* Type accent strip */
.k-card--person { border-left: 3px solid #10b981; }
.k-card--place { border-left: 3px solid #f59e0b; }
.k-card--list { border-left: 3px solid #38bdf8; }
.k-card--note { border-left: 3px solid #6366f1; }
.k-card--task { border-left: 3px solid #a78bfa; }
```
Replace with:
```css
/* Type-specific card DNA */
.k-card--note { border-color: rgba(124, 58, 237, 0.12); }
.k-card--task { border-color: rgba(212, 160, 23, 0.10); }
.k-card--person { border-color: rgba(16, 185, 129, 0.10); }
.k-card--place { border-color: rgba(245, 158, 11, 0.10); }
.k-card--list { border-color: rgba(56, 189, 248, 0.10); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before,
.k-card--list::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #7c3aed, #a78bfa);
}
.k-card--task::before {
width: 50%;
background: linear-gradient(90deg, #d4a017, transparent);
}
.k-card--list::before {
right: 0;
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
}
/* Corner accents for entity types */
.k-card--person::after,
.k-card--place::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 60px;
border-radius: 0 14px 0 60px;
pointer-events: none;
}
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
```
- [ ] **Step 2: Update card hover to violet bloom**
Find:
```css
.k-card:hover {
border-color: rgba(255,255,255,0.14);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
```
Replace with:
```css
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(124, 58, 237, 0.2);
}
```
- [ ] **Step 3: Add sidebar section dividers**
Find:
```css
.filter-section { margin-bottom: 20px; }
```
Replace with:
```css
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(124, 58, 237, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
```
- [ ] **Step 4: Add scroll fade to card grid**
Find:
```css
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
```
Replace with:
```css
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
```
- [ ] **Step 5: Update task card dates to use amber**
Find in the task card CSS:
```css
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
```
Replace with:
```css
.task-due {
font-size: 0.78rem;
color: var(--color-accent-warm);
}
```
- [ ] **Step 6: Add Fraunces view title and update sidebar labels**
Find the filter panel label CSS:
```css
.filter-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted);
margin-bottom: 6px;
padding: 0 4px;
}
```
Replace with:
```css
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--color-primary);
margin-bottom: 6px;
padding: 0 4px;
}
```
- [ ] **Step 7: Update empty state with Fraunces narrator voice**
Find:
```html
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p>Nothing here yet.</p>
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
</div>
```
Replace with:
```html
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
</div>
```
Add CSS:
```css
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 1rem;
color: var(--color-accent-warm);
opacity: 0.85;
}
```
- [ ] **Step 8: Update card date stamps to amber**
Find:
```css
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
```
Replace with:
```css
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states"
```
---
### Task 4: ChatPanel + BriefingView + CalendarView — empty states + glow buttons
**Files:**
- Modify: `frontend/src/components/ChatPanel.vue`
- Modify: `frontend/src/views/BriefingView.vue`
- Modify: `frontend/src/views/CalendarView.vue`
- Modify: `frontend/src/App.vue`
- [ ] **Step 1: Update ChatPanel empty state**
In `frontend/src/components/ChatPanel.vue`, find:
```html
>Send a message to start the conversation.</p>
```
Replace with:
```html
>Start a conversation.</p>
```
Find the `.empty-msg` CSS:
```css
.empty-msg {
```
Add these properties (find the existing block and add to it):
```css
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: var(--color-accent-warm, #d4a017);
```
- [ ] **Step 2: Add scroll fade to ChatPanel messages**
Find the `.messages-container` CSS in ChatPanel.vue. Add:
```css
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
```
- [ ] **Step 3: Update CalendarView empty state**
In `frontend/src/views/CalendarView.vue`, find:
```html
return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
```
Wait — that's the Flutter file. In the web CalendarView, there's no dedicated empty state text to update since it's a FullCalendar component. Skip this for the web CalendarView — it doesn't have a custom empty state.
- [ ] **Step 4: Add glow to primary action buttons in App.vue global styles**
In `frontend/src/App.vue`, find the `<style>` block (the global unscoped one). The `btn-send` styles are in `ChatInputBar.vue` which is scoped. Instead, add a global hover glow rule. Find the existing `.app-footer` style and add after it:
No — the glow should be on the specific button components. The `btn-send` in `ChatInputBar.vue` already has a hover shadow. Let me update it there.
In `frontend/src/components/ChatInputBar.vue`, find:
```css
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
```
Replace with:
```css
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
```
- [ ] **Step 5: Update KnowledgeView new-note button glow**
In `frontend/src/views/KnowledgeView.vue`, find:
```css
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
```
Replace with:
```css
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
```
- [ ] **Step 6: Update any remaining hardcoded indigo references in KnowledgeView**
Search for `99, 102, 241` in KnowledgeView.vue and replace with `124, 58, 237`. This covers all the rgba references in filter buttons, borders, today bar chips, etc.
Use find-and-replace across the file: `99, 102, 241``124, 58, 237`
- [ ] **Step 7: Update hardcoded indigo in BriefingView**
Search for `99, 102, 241` in BriefingView.vue and replace with `124, 58, 237`.
Search for `6366f1` in BriefingView.vue and replace with `7c3aed`.
- [ ] **Step 8: Update hardcoded indigo in AppHeader**
Search for `99, 102, 241` in AppHeader.vue and replace with `124, 58, 237` (for any remaining references not covered by Task 2).
- [ ] **Step 9: Update hardcoded indigo in App.vue shortcuts overlay**
Search for `99, 102, 241` in App.vue and replace with `124, 58, 237`.
Search for `6366f1` in App.vue and replace with `7c3aed`.
- [ ] **Step 10: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 11: Commit**
```bash
git add frontend/src/components/ChatPanel.vue frontend/src/components/ChatInputBar.vue \
frontend/src/views/KnowledgeView.vue frontend/src/views/BriefingView.vue \
frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: narrator empty states, scroll fades, glow buttons, violet color sweep"
```
@@ -0,0 +1,144 @@
# Knowledge View Task Consolidation — Design Spec
## Goal
Consolidate tasks into the Knowledge view as a card type, deprecate the standalone `/notes` and `/tasks` list views, and simplify navigation. The Knowledge view becomes the single hub for all content types: notes, tasks, people, places, and lists.
## Architecture
The Knowledge view already renders notes, people, places, and lists as typed cards in a filterable grid with a sidebar. Tasks are added as a fifth card type using the same two-tier pagination system (ID pre-fetch → content batch). The backend knowledge endpoints (`/api/knowledge/ids`, `/api/knowledge/batch`, `/api/knowledge/counts`) are extended to include tasks. No changes to the note/task CRUD API.
## Task Cards
Task cards follow the same layout as other knowledge cards:
- **Left accent strip**: distinct color for tasks (e.g. `#a78bfa` purple to differentiate from note indigo)
- **Type badge**: "Task" in top-right corner
- **Card body**:
- Title (2-line clamp)
- Status badge: `todo` / `in_progress` / `done` / `cancelled` — styled with existing status colors from theme (`--color-status-*`)
- Priority indicator: shown only when priority is not `none` — uses existing priority colors (`--color-priority-*`)
- Due date: shown when set, with overdue styling (`--color-overdue`) when past and status is not `done`/`cancelled`
- **Card footer**: tags (up to 3) + last-modified date — identical to other card types
Clicking a task card navigates to `/tasks/:id/edit` (same as today).
## Filter Sidebar Changes
The type filter section gains a "Tasks" button:
```
Type
──────────
[All] 127
[Notes] 84
[Tasks] 22
[People] 8
[Places] 5
[Lists] 8
```
The filter value for tasks is `type=task`. The backend already stores tasks as notes with `is_task=True`; the knowledge endpoints need to map the `type=task` filter to `is_task=True`.
## New Note Button Interaction
Current: click "New note" to create a note; chevron expands a dropdown with Note/Person/Place/List.
New behavior:
1. **Click "New note"** (when collapsed) → expands to reveal type options: Task, Person, Place, List. The main button label does not change.
2. **Click "New note"** again (when expanded) → navigates to `/notes/new` (generic note).
3. **Click any type option** → navigates to `/notes/new?type=<type>` (for task: `/notes/new?type=task`, which is equivalent to `/tasks/new`).
4. **Click outside** → collapses the dropdown.
This replaces the current chevron split-button pattern with a simpler toggle. The dropdown items are: Task, Person, Place, List (no "Note" item in the dropdown — clicking the button itself creates a note).
## Route Changes
### Redirects
| Old route | New behavior |
|-----------|-------------|
| `/notes` | 302 redirect → `/` (Knowledge view) |
| `/tasks` | 302 redirect → `/` (Knowledge view) |
### Preserved routes (no change)
| Route | Purpose |
|-------|---------|
| `/notes/:id` | Note viewer |
| `/notes/:id/edit` | Note editor |
| `/notes/new` | New note (with optional `?type=` param) |
| `/tasks/:id/edit` | Task editor |
| `/tasks/new` | New task |
### Router implementation
Add redirect entries in the router config:
```ts
{ path: '/notes', redirect: '/' },
{ path: '/tasks', redirect: '/' },
```
### Navigation
Remove from `AppHeader.vue`:
- "Tasks" nav link (`<router-link to="/tasks">`)
- The `/tasks` entry in both desktop nav-center and mobile menu
Remove from `AppHeader.vue` (already done — `/notes` was removed in a prior change, but verify).
### Deleted files
- `frontend/src/views/NotesListView.vue`
- `frontend/src/views/TasksListView.vue`
- `frontend/src/stores/notes.ts` (if only used by NotesListView)
- `frontend/src/stores/tasks.ts` (if only used by TasksListView)
Verify no other components import from these before deleting. The note/task viewer and editor screens import from `api/client.ts` directly, not from the list stores.
## Backend Changes
### `/api/knowledge/ids`
Accept `type=task` as a valid filter. When `type=task`, query `notes` table with `is_task = True`. When `type` is not set (all), include tasks in results alongside notes/people/places/lists.
### `/api/knowledge/batch`
Return task-specific fields for items where `is_task = True`:
- `status`: todo / in_progress / done / cancelled
- `priority`: none / low / normal / high
- `due_date`: ISO date string or null
These are already columns on the `Note` model — just include them in the batch response when the item is a task.
### `/api/knowledge/counts`
Add `task` to the counts response:
```json
{ "note": 84, "task": 22, "person": 8, "place": 5, "list": 8, "total": 127 }
```
### `/api/knowledge/tags`
No change — tasks already have tags on the same `Note` model.
## Keyboard Shortcuts
Remove from `App.vue` `onGlobalKeydown`:
- `case "t": router.push("/tasks/new")` — keep this, it still works
- `case "g"` sequence `case "t": router.push("/tasks")` — change to `router.push("/")` (direct navigation, don't rely on redirect)
Update shortcuts overlay panel text if it references "Tasks list".
## No API Endpoint Changes
All existing REST endpoints remain:
- `GET/POST /api/notes` — notes CRUD
- `GET/POST /api/tasks` — tasks CRUD
- `PATCH /api/notes/:id`, `PATCH /api/tasks/:id`
- `DELETE /api/notes/:id`, `DELETE /api/tasks/:id`
MCP tools (`fable_create_task`, `fable_list_tasks`, etc.) are unaffected.
@@ -0,0 +1,204 @@
# Specialized Note Type Editors — Design Spec
## Goal
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
## Architecture
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
## Person Editor
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
### Fields (in order, all in main content area)
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Name" | `title` |
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
| Birthday | date input | — | `entityMeta.birthday` (new field) |
| Email | email input | "email@example.com" | `entityMeta.email` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
### Notes section
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ Name: [________________________________] │
│ │
│ Relationship: [________________________] │
│ Birthday: [____date picker________] │
│ Email: [________________________] │
│ Phone: [________________________] │
│ Organization: [________________________] │
│ Address: [________________________] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (markdown body) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
### Data migration
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
## Place Editor
When `noteType === 'place'`, same form-first pattern.
### Fields
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Place name" | `title` |
| Address | text input | "Street, City, State" | `entityMeta.address` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Hours | text input | "e.g. MonFri 9am5pm" | `entityMeta.hours` |
| Website | url input | "https://..." | `entityMeta.website` (new field) |
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
### Notes section
Same as Person — collapsible TipTap editor below the form.
## List Editor
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
### List builder
Each list item is a row with:
- Checkbox (toggle checked state)
- Text input (item text, fills available width)
- Delete button (× icon, right side)
Below the items: an "Add item" button.
### Behavior
- **Enter** in any item input: creates a new item below and focuses it
- **Backspace** on an empty item: deletes the item and focuses the previous one
- **Checkbox toggle**: updates the item's checked state
- **Delete button**: removes the item
### Serialization
On save, list items are serialized to markdown checkbox format in the body:
```markdown
- [ ] Buy groceries
- [x] Call dentist
- [ ] Pick up prescription
```
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
### Notes section
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ List title: [____________________________│
│ │
│ [ ] Buy groceries [×] │
│ [x] Call dentist [×] │
│ [ ] Pick up prescription [×] │
│ │
│ [+ Add item] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (additional context) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
## Tab Navigation & Auto-Focus
### All note types
1. **On page load**: focus the title/name input automatically
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
- Note: TipTap editor body
- Person: Relationship field
- Place: Address field
- List: first list item (or "Add item" button if empty)
3. **Tab through fields**: natural order through all form fields
4. **Tab from last form field**: enter the Notes section (TipTap editor)
### Implementation
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
### Title placeholder by type
| Type | Placeholder |
|------|-------------|
| Note | "Title" |
| Person | "Name" |
| Place | "Place name" |
| List | "List title" |
| Task | "Title" (unchanged, task editor is separate) |
## Sidebar changes
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
## Backend changes
### New entity metadata fields
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
### Knowledge service
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
- Person: add `birthday`, `organization`, `address`
- Place: add `website`, `category`
### Knowledge card display
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
## Files changed
| File | Change |
|------|--------|
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
## What does NOT change
- Note model / database schema (entity_meta is already a JSON column)
- API endpoints (same CRUD)
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
- Backend storage format — entity_meta key-value pairs
@@ -0,0 +1,249 @@
# Modern Fable — Visual Identity Design Spec
## Goal
Replace the generic "competent dark-mode Vue app" aesthetic with a distinctive visual identity that is unmistakably Fabled Assistant. The design language evolves from "Illuminated Transcript" to "Modern Fable" — keeping the scholarly DNA but adding personality through color, typography, interaction, and card design that no other app has.
## Color Palette
Shift from indigo (`#6366f1`) to deep violet + muted gold.
### Dark theme
| Role | Old | New | Usage |
|------|-----|-----|-------|
| Primary | `#818cf8` | `#a78bfa` | Text accents, active states, tags, links |
| Primary solid | `#6366f1` | `#7c3aed` | Buttons, gradients, accent strips |
| Primary deep | `#4f46e5` | `#5b21b6` | Gradient endpoints, hover states |
| Accent (warm) | — | `#d4a017` | Due dates, event times, counts, temporal data |
| Accent light | — | `#e8c45a` | Amber hover states |
| Background | `#111113` | `#0f0f14` | Slightly deeper, more dramatic |
| Surface | `#1a1b22` | `#16161f` | Cards, panels |
| Card bg | `#1e1e27` | `#1a1a24` | Card interiors |
| Border | `rgba(99,102,241,0.10)` | `rgba(124,58,237,0.12)` | Violet-tinted borders |
| Text | `#e4e4f0` | `#e4e4f0` | Unchanged |
| Text muted | `#52526a` | `#52526a` | Unchanged |
### Light theme
| Role | Old | New |
|------|-----|-----|
| Primary | `#6366f1` | `#7c3aed` |
| Primary text | `#4f46e5` | `#5b21b6` |
| Accent | — | `#b8860b` (darker gold for light bg) |
| Tag bg | `#ede9fe` | `#ede5ff` |
| Tag text | `#4f46e5` | `#6d28d9` |
### Semantic color rules
- **Violet = structural** — navigation, type badges, status indicators, card accents, CTA buttons
- **Amber/gold = temporal** — due dates, event times, countdown values, "overdue" states, calendar dot, relative timestamps
- This duality is a core brand principle: violet organizes, amber marks time
### Logo update
Update `AppLogo.vue` SVG fill to use the new violet gradient (`#7c3aed``#5b21b6`) instead of the current indigo values.
## Card Design — Type DNA
Each content type gets a distinct visual signature recognizable at a glance without reading the badge.
### Shared card structure
- Background: `var(--color-surface)`
- Border: `1px solid` with type-tinted color at low opacity
- Border-radius: `var(--radius-lg)` (14px)
- Padding: 14px
- Hover: translateY(-2px) + violet shadow bloom (`0 8px 24px rgba(124,58,237,0.15)`)
### Type-specific signatures
**Note** (`note`)
- Top edge: full-width 3px gradient bar (`#7c3aed``#a78bfa`)
- Border tint: `rgba(124,58,237,0.12)`
- Badge color: `#a78bfa`
**Task** (`task`)
- Top edge: half-width 3px gradient bar (`#d4a017` → transparent`), left-aligned — partial bar suggests "in progress"
- Border tint: `rgba(212,160,23,0.10)`
- Badge color: `#d4a017`
- Status badge inline with type badge row
- Due date in amber; overdue in `--color-overdue` (red)
**Person** (`person`)
- Top edge: none
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(16,185,129,0.06)`)
- Border tint: `rgba(16,185,129,0.10)`
- Badge color: `#34d399`
**Place** (`place`)
- Top edge: none
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(245,158,11,0.06)`)
- Border tint: `rgba(245,158,11,0.10)`
- Badge color: `#fbbf24`
**List** (`list`)
- Top edge: full-width 3px gradient bar (`#38bdf8``#7dd3fc`)
- Border tint: `rgba(56,189,248,0.10)`
- Badge color: `#7dd3fc`
- Progress bar beneath checkboxes
### Card hover state
All cards share the same hover treatment:
```css
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124,58,237,0.15), 0 2px 8px rgba(0,0,0,0.3);
border-color: rgba(124,58,237,0.2);
}
```
## Navigation — Signature Header
Replace the flat nav links with a pill-grouped tab bar.
### Structure
```
[Logo + "Fabled"] [ Knowledge | Chat | Briefing | Calendar | News | Projects ] [status · ? · ☀ · ⚙ · user]
```
### Brand in header
- Logo: `AppLogo` SVG at 28px with new violet gradient
- Text: "Fabled" only (not "Fabled Assistant") — Fraunces italic, `#c4b0f0`, 15px
- The full name "Fabled Assistant" appears on the login page and Settings; the header uses the short form
### Tab bar
- Container: `rgba(124,58,237,0.06)` background, `border-radius: 10px`, 3px padding
- Inactive tabs: transparent background, `color: var(--color-text-muted)`
- Active tab: `rgba(124,58,237,0.2)` background, `border-radius: 8px`, `color: #c4b5fd`, soft box-shadow glow `0 0 12px rgba(124,58,237,0.2)`
- Hover (inactive): `rgba(124,58,237,0.08)` background
- Transition: background 0.15s, color 0.15s
### Header background
Subtle gradient: `linear-gradient(180deg, var(--color-surface), var(--color-bg))` with a bottom border of `rgba(124,58,237,0.08)`. Creates depth without being heavy.
### Mobile
On mobile (< 768px), the pill bar collapses into the existing hamburger dropdown menu. The dropdown gets the same violet active styling.
## Typography — Fraunces as Narrator
Fraunces italic becomes the "narrator's voice" of the application — the assistant speaking through the UI. System UI font remains for body text and interactive elements.
### Where Fraunces is used
| Element | Style | Example |
|---------|-------|---------|
| View titles | Fraunces italic, 20-24px, `#c4b0f0` | *Knowledge*, *Chat*, *Briefing* |
| Sidebar section labels | Fraunces italic, 11px, `var(--color-primary)` | *Filter*, *Tags*, *Sort* |
| Empty states | Fraunces italic, 13-15px, `#d4a017` | *"Every story starts with a blank page."* |
| Card headings (h1/h2/h3) | Fraunces, non-italic, 600 weight | Existing behavior, unchanged |
| Briefing greeting | Fraunces italic, 16px | *"Good morning, Bryan"* |
### Where Fraunces is NOT used
- Navigation tab labels (system font, 12-13px)
- Buttons and form labels
- Card body text, snippets, metadata
- Toast messages, error text
### Empty state voice
Each major view gets a distinctive empty state message in Fraunces italic, amber color:
- Knowledge: *"Your story is unwritten. Create your first note to begin."*
- Chat: *"Start a conversation."*
- Calendar: *"No events ahead. A quiet chapter."*
- Briefing (no briefing yet): *"Your daily briefing will appear here each morning."*
## Living Details
Small touches that accumulate into a distinctive feel.
### Glow interactions
- **Buttons**: Primary buttons (`btn-send`, `btn-new-note`, CTAs) get a violet glow on hover: `box-shadow: 0 0 16px rgba(124,58,237,0.35)`
- **Focus ring**: Change from current `color-mix` to a violet glow: `0 0 0 2px rgba(124,58,237,0.4)`
- **Active nav tab**: Soft glow behind the active pill (see Navigation section)
### Amber for temporal data
Consistently use `#d4a017` (dark theme) for all time-related information:
- Due dates on task cards
- Event times on calendar chips
- "3d ago" timestamps on cards
- Overdue badge in the today bar
- Countdown/relative time in briefing
This creates a visual language: when you see amber, it's about *when*.
### Card hover bloom
Cards lift and emit a violet shadow on hover (see Card Design section). The shadow color matches the card's type accent at very low opacity for a subtle differentiation.
### Status dot pulse
The Ollama status indicator in the header gains a CSS pulse animation when the model is loaded:
```css
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74,222,128,0.4); }
50% { box-shadow: 0 0 10px rgba(74,222,128,0.6); }
}
```
Pulse only when status is "loaded" (green). Offline (red) and loading (amber) are static.
### Scroll edge fades
Top and bottom edges of scrollable areas (card grid, chat messages, sidebar tag list) get a gradient mask that fades content into the background. 20px height, using `mask-image: linear-gradient(...)`.
### Sidebar section dividers
Replace flat `border-bottom` between filter sections with a centered ornamental divider:
```css
.filter-section + .filter-section::before {
content: '·';
display: block;
text-align: center;
color: rgba(124,58,237,0.3);
font-size: 1.2rem;
letter-spacing: 0.5em;
padding: 8px 0;
}
```
Three centered dots (` · · · `) in faint violet. Subtle but distinctive.
### Scrollbar
Keep the current thin scrollbar but update the color from indigo to violet:
```css
::-webkit-scrollbar-thumb {
background: rgba(124,58,237,0.25);
}
```
## Files Changed
| File | Change |
|------|--------|
| `frontend/src/assets/theme.css` | Full palette update (both light and dark), scrollbar color |
| `frontend/src/components/AppLogo.vue` | SVG fill gradient update |
| `frontend/src/components/AppHeader.vue` | Pill-grouped nav tabs, brand shortening, header gradient, status pulse |
| `frontend/src/views/KnowledgeView.vue` | Card type DNA (gradient bars, corner accents), hover bloom, section dividers, empty state text, scroll fades, Fraunces view title |
| `frontend/src/components/ChatPanel.vue` | Scroll fade on messages, empty state text |
| `frontend/src/views/CalendarView.vue` | Empty state text, amber event times |
| `frontend/src/views/BriefingView.vue` | Empty state text, Fraunces greeting |
| `frontend/src/views/ChatView.vue` | (uses ChatPanel — inherits changes) |
| `frontend/src/App.vue` | Update any global styles referencing old indigo values |
## What Does NOT Change
- Overall layout structure (sidebar + content + optional graph panel)
- Chat bubble design (user transparent, assistant border-left + shadow)
- TipTap editor styling
- Settings view layout
- Backend — zero changes
- Mobile layout patterns
+2 -2
View File
@@ -90,7 +90,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
switch (e.key) { switch (e.key) {
case "h": router.push("/"); break; case "h": router.push("/"); break;
case "n": router.push("/notes"); break; case "n": router.push("/notes"); break;
case "t": router.push("/tasks"); break; case "t": router.push("/"); break;
case "p": router.push("/projects"); break; case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break; case "c": router.push("/chat"); break;
case "g": router.push("/graph"); break; case "g": router.push("/graph"); break;
@@ -202,7 +202,7 @@ onUnmounted(() => {
<kbd class="shortcut-key">g</kbd> <kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span> <span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">t</kbd> <kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Tasks</span> <span class="shortcut-desc">Knowledge (tasks)</span>
</div> </div>
<div class="shortcut-row"> <div class="shortcut-row">
<kbd class="shortcut-key">g</kbd> <kbd class="shortcut-key">g</kbd>
+46 -23
View File
@@ -10,17 +10,17 @@
--color-text-muted: #999999; --color-text-muted: #999999;
--color-border: #dddde8; --color-border: #dddde8;
--color-input-border: #c8c8d8; --color-input-border: #c8c8d8;
--color-primary: #6366f1; --color-primary: #7c3aed;
--color-danger: #d93025; --color-danger: #d93025;
--color-tag-bg: #ede9fe; --color-tag-bg: #ede5ff;
--color-tag-text: #4f46e5; --color-tag-text: #6d28d9;
--color-shadow: rgba(0, 0, 0, 0.08); --color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853; --color-toast-success: #34a853;
--color-toast-error: #d93025; --color-toast-error: #d93025;
--color-status-todo: #5f6368; --color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed; --color-status-todo-bg: #e8eaed;
--color-status-in-progress: #6366f1; --color-status-in-progress: #7c3aed;
--color-status-in-progress-bg: #ede9fe; --color-status-in-progress-bg: #ede5ff;
--color-status-done: #34a853; --color-status-done: #34a853;
--color-status-done-bg: #e6f4ea; --color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0; --color-priority-low: #5f9ea0;
@@ -44,38 +44,50 @@
--color-bubble-user-bg: rgba(0, 0, 0, 0.04); --color-bubble-user-bg: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10); --color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a; --color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06); --color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 12px; --radius-md: 12px;
--radius-lg: 18px; --radius-lg: 18px;
--radius-pill: 9999px; --radius-pill: 9999px;
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent); --focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
/* Layout */ /* Layout */
--page-max-width: 1200px; --page-max-width: 1200px;
--page-padding-x: 1rem; --page-padding-x: 1rem;
--sidebar-width: 260px; --sidebar-width: 260px;
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
/* Reusable brand patterns */
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
--color-primary-faint: rgba(124, 58, 237, 0.08);
--color-primary-tint: rgba(124, 58, 237, 0.12);
--color-primary-wash: rgba(124, 58, 237, 0.20);
} }
[data-theme="dark"] { [data-theme="dark"] {
--color-bg: #111113; --color-bg: #0f0f14;
--color-bg-secondary: #18181f; --color-bg-secondary: #16161f;
--color-bg-card: #1e1e27; --color-bg-card: #1a1a24;
--color-surface: #1a1b22; --color-surface: #16161f;
--color-text: #e4e4f0; --color-text: #e4e4f0;
--color-text-secondary: #8888a8; --color-text-secondary: #8888a8;
--color-text-muted: #52526a; --color-text-muted: #52526a;
--color-border: rgba(99, 102, 241, 0.10); --color-border: rgba(124, 58, 237, 0.22);
--color-input-border: rgba(99, 102, 241, 0.22); --color-input-border: rgba(124, 58, 237, 0.35);
--color-primary: #818cf8; --color-primary: #a78bfa;
--color-danger: #f44336; --color-danger: #f44336;
--color-tag-bg: #2a2a45; --color-tag-bg: #2a2a45;
--color-tag-text: #a5b4fc; --color-tag-text: #c4b5fd;
--color-shadow: rgba(0, 0, 0, 0.4); --color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50; --color-toast-success: #4caf50;
--color-toast-error: #f44336; --color-toast-error: #f44336;
--color-status-todo: #9aa0a6; --color-status-todo: #9aa0a6;
--color-status-todo-bg: #2a2a35; --color-status-todo-bg: #2a2a35;
--color-status-in-progress: #818cf8; --color-status-in-progress: #a78bfa;
--color-status-in-progress-bg: #2a2a45; --color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50; --color-status-done: #4caf50;
--color-status-done-bg: #1b3a20; --color-status-done-bg: #1b3a20;
@@ -88,19 +100,30 @@
--color-wikilink: #c4b5fd; --color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45; --color-wikilink-bg: #2a1a45;
--color-overdue: #f44336; --color-overdue: #f44336;
--color-code-bg: #16161d; --color-code-bg: #12121a;
--color-code-inline-bg: #1e1e2d; --color-code-inline-bg: #1a1a2a;
--color-table-stripe: #16161e; --color-table-stripe: #14141e;
--color-success: #4ade80; --color-success: #4ade80;
--color-warning: #facc15; --color-warning: #facc15;
--color-input-bar-bg: #1e1e27; --color-input-bar-bg: #1a1a24;
--color-input-bar-text: #e4e4f0; --color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35); --color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65); --color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04); --color-bubble-user-bg: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10); --color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8; --color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4); --color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017;
--color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 12px rgba(124, 58, 237, 0.45);
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65);
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4);
--color-primary-faint: rgba(124, 58, 237, 0.10);
--color-primary-tint: rgba(124, 58, 237, 0.14);
--color-primary-wash: rgba(124, 58, 237, 0.22);
} }
*, *,
@@ -168,11 +191,11 @@ button:not(:disabled):active,
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25); background: rgba(124, 58, 237, 0.25);
border-radius: 9999px; border-radius: 9999px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45); background: rgba(124, 58, 237, 0.45);
} }
/* Floating inline assist button (teleported to body, cannot be scoped) */ /* Floating inline assist button (teleported to body, cannot be scoped) */
+50 -30
View File
@@ -68,18 +68,19 @@ router.afterEach(() => {
<!-- Left: brand --> <!-- Left: brand -->
<router-link to="/" class="nav-brand"> <router-link to="/" class="nav-brand">
<AppLogo :size="34" /> <AppLogo :size="34" />
Fabled Assistant <span class="brand-text">Fabled</span>
</router-link> </router-link>
<!-- Center: primary navigation (desktop) --> <!-- Center: primary navigation (desktop) -->
<div class="nav-center"> <div class="nav-center">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link> <div class="nav-pill-bar">
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link> <router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link> <router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link> <router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link> <router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link> <router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link> <router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div> </div>
<!-- Right: status + utilities + gear + user --> <!-- Right: status + utilities + gear + user -->
@@ -126,7 +127,6 @@ router.afterEach(() => {
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link> <router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link> <router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link> <router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link> <router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/news" class="nav-link">News</router-link> <router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link> <router-link to="/shared" class="nav-link">Shared</router-link>
@@ -152,40 +152,51 @@ router.afterEach(() => {
<style scoped> <style scoped>
.app-header { .app-header {
background: var(--color-bg-secondary); background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
position: relative; position: relative;
} }
.nav { .nav {
padding: 0.75rem 1.5rem; padding: 0.6rem 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
position: relative; position: relative;
} }
/* Left */ /* Left — brand */
.nav-brand { .nav-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.45rem;
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1.15rem;
letter-spacing: -0.01em;
color: var(--color-primary);
text-decoration: none; text-decoration: none;
flex-shrink: 0; flex-shrink: 0;
} }
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
}
/* Center — absolutely positioned so it's truly centered regardless of side widths */ /* Center — pill bar */
.nav-center { .nav-center {
position: absolute; position: absolute;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.1rem; }
.nav-pill-bar {
display: flex;
align-items: center;
gap: 2px;
background: var(--color-primary-faint);
border-radius: 10px;
padding: 3px;
} }
/* Right */ /* Right */
@@ -199,20 +210,20 @@ router.afterEach(() => {
.nav-link { .nav-link {
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.82rem;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.75rem;
border-radius: var(--radius-sm); border-radius: 8px;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.nav-link:hover { .nav-link:hover {
color: var(--color-primary); color: var(--color-text);
background: color-mix(in srgb, var(--color-primary) 8%, transparent); background: var(--color-primary-tint);
} }
.nav-link.router-link-active { .nav-link.router-link-active {
color: var(--color-primary); color: var(--color-primary-solid);
font-weight: 600; font-weight: 600;
box-shadow: inset 0 -2px 0 var(--color-primary); background: rgba(124, 58, 237, 0.25);
border-radius: 0; box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
} }
/* Status indicator */ /* Status indicator */
@@ -234,7 +245,7 @@ router.afterEach(() => {
font-weight: 500; font-weight: 500;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.status-green .status-dot { background: var(--color-success, #2ecc71); } .status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; } .status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; } .status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: var(--color-danger, #e74c3c); } .status-red .status-dot { background: var(--color-danger, #e74c3c); }
@@ -243,6 +254,10 @@ router.afterEach(() => {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.3; } 50% { opacity: 0.3; }
} }
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
/* Icon buttons (?, theme, gear) */ /* Icon buttons (?, theme, gear) */
.btn-icon { .btn-icon {
@@ -369,6 +384,11 @@ router.afterEach(() => {
min-height: 44px; min-height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: var(--color-primary-wash);
box-shadow: none;
} }
.mobile-user .btn-logout { .mobile-user .btn-logout {
min-height: 36px; min-height: 36px;
+7 -1
View File
@@ -11,6 +11,12 @@ defineProps<{ size?: number }>();
:height="size ?? 24" :height="size ?? 24"
aria-hidden="true" aria-hidden="true"
> >
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
<!-- Book body --> <!-- Book body -->
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/> <path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine --> <!-- Book spine -->
@@ -37,7 +43,7 @@ defineProps<{ size?: number }>();
<style scoped> <style scoped>
.logo-book { .logo-book {
fill: var(--color-primary); fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent); stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
} }
.logo-spine { .logo-spine {
@@ -247,7 +247,7 @@ async function finish() {
} }
.wizard-progress-fill { .wizard-progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #6366f1, #4f46e5); background: var(--gradient-cta);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.wizard-step-label { .wizard-step-label {
@@ -375,7 +375,7 @@ async function finish() {
} }
.btn-wizard-primary { .btn-wizard-primary {
padding: 0.5rem 1.25rem; padding: 0.5rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
+2 -2
View File
@@ -391,7 +391,7 @@ defineExpose({ focus, prefill })
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
@@ -400,7 +400,7 @@ defineExpose({ focus, prefill })
flex-shrink: 0; flex-shrink: 0;
transition: box-shadow 0.15s; transition: box-shadow 0.15s;
} }
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); } .btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; } .btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
.btn-abort-inline { .btn-abort-inline {
+6 -1
View File
@@ -338,7 +338,7 @@ defineExpose({ focus, prefill, send })
<p <p
v-if="!store.currentConversation?.messages.length && !store.streaming" v-if="!store.currentConversation?.messages.length && !store.streaming"
class="empty-msg" class="empty-msg"
>Send a message to start the conversation.</p> >Start a conversation.</p>
</div> </div>
</div> </div>
@@ -525,6 +525,8 @@ defineExpose({ focus, prefill, send })
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1rem;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
} }
.messages-inner { .messages-inner {
display: flex; display: flex;
@@ -746,6 +748,9 @@ defineExpose({ focus, prefill, send })
font-size: 0.9rem; font-size: 0.9rem;
text-align: center; text-align: center;
padding: 2rem 1rem; padding: 2rem 1rem;
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: var(--color-accent-warm, #d4a017);
} }
/* ── Widget variant ── */ /* ── Widget variant ── */
+105 -16
View File
@@ -64,36 +64,114 @@ function toIso(date: string, time: string): string {
return `${date}T${time}:00${sign}${h}:${min}`; return `${date}T${time}:00${sign}${h}:${min}`;
} }
// ── Time helpers ──────────────────────────────────────────────────────────────
/** Round up to next 30-minute boundary */
function nextRoundedTime(): string {
const now = new Date();
let h = now.getHours();
let m = now.getMinutes();
if (m <= 30) { m = 30; }
else { m = 0; h = (h + 1) % 24; }
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Add hours to a time string (HH:MM), returns HH:MM */
function addHours(time: string, hours: number): string {
const [h, m] = time.split(":").map(Number);
const totalMin = h * 60 + m + hours * 60;
const nh = Math.floor(totalMin / 60) % 24;
const nm = totalMin % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
/** Duration in minutes between two time strings */
function durationMin(start: string, end: string): number {
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
return (eh * 60 + em) - (sh * 60 + sm);
}
/** True if a date+time is in the past */
const isPastEvent = computed(() => {
if (allDay.value || !startDate.value || !startTime.value) return false;
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
return dt.getTime() < Date.now();
});
// Track duration so end moves with start
let _lastDurationMin = 60;
function resetForm() { function resetForm() {
if (props.event) { if (props.event) {
title.value = props.event.title; title.value = props.event.title;
allDay.value = props.event.all_day; allDay.value = props.event.all_day;
// All-day events: use UTC date string directly to avoid timezone shifting
// (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones)
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt); startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt); startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : ""; endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : ""; endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
description.value = props.event.description || ""; description.value = props.event.description || "";
location.value = props.event.location || ""; location.value = props.event.location || "";
color.value = props.event.color || ""; color.value = props.event.color || "";
projectId.value = props.event.project_id; projectId.value = props.event.project_id;
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
if (_lastDurationMin <= 0) _lastDurationMin = 60;
} else { } else {
title.value = ""; title.value = "";
allDay.value = false; allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10); const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
const roundedStart = nextRoundedTime();
startDate.value = base; startDate.value = base;
startTime.value = "09:00"; startTime.value = roundedStart;
endDate.value = base; endDate.value = base;
endTime.value = "10:00"; endTime.value = addHours(roundedStart, 1);
description.value = ""; description.value = "";
location.value = ""; location.value = "";
color.value = ""; color.value = "";
projectId.value = null; projectId.value = null;
_lastDurationMin = 60;
} }
deleteConfirm.value = false; deleteConfirm.value = false;
} }
// When start time changes, move end time to preserve duration
watch(startTime, (newStart) => {
if (allDay.value || !newStart) return;
endTime.value = addHours(newStart, _lastDurationMin / 60);
});
// When start date changes, move end date to match (preserve same-day or multi-day gap)
watch(startDate, (newDate) => {
if (!newDate) return;
endDate.value = newDate;
});
// When end time changes manually, update the tracked duration (but guard against end < start)
watch(endTime, (newEnd) => {
if (allDay.value || !newEnd || !startTime.value) return;
const dur = durationMin(startTime.value, newEnd);
if (dur <= 0) {
// Snap back to start + 1 hour
endTime.value = addHours(startTime.value, 1);
_lastDurationMin = 60;
} else {
_lastDurationMin = dur;
}
});
// All-day toggle: clear/restore times
watch(allDay, (isAllDay) => {
if (isAllDay) {
startTime.value = "";
endTime.value = "";
} else {
const rounded = nextRoundedTime();
startTime.value = rounded;
endTime.value = addHours(rounded, 1);
_lastDurationMin = 60;
}
});
watch(() => props.event, resetForm, { immediate: true }); watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm); watch(() => props.initialDate, resetForm);
@@ -113,6 +191,10 @@ async function save() {
toast.show("Start date is required", "error"); toast.show("Start date is required", "error");
return; return;
} }
if (!allDay.value && !startTime.value) {
toast.show("Start time is required", "error");
return;
}
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value); const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value const end_dt = endDate.value
@@ -199,18 +281,19 @@ async function doDelete() {
<!-- Start --> <!-- Start -->
<div class="so-field"> <div class="so-field">
<label class="so-label">Start</label> <label class="so-label">Start <span class="required">*</span></label>
<div class="dt-row"> <div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" /> <input v-model="startDate" type="date" class="so-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" /> <input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
</div> </div>
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
</div> </div>
<!-- End --> <!-- End -->
<div class="so-field"> <div class="so-field">
<label class="so-label">End <span class="so-hint">(optional)</span></label> <label class="so-label">End</label>
<div class="dt-row"> <div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" /> <input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" /> <input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
</div> </div>
</div> </div>
@@ -232,7 +315,7 @@ async function doDelete() {
<label class="so-label so-label-inline">Color</label> <label class="so-label so-label-inline">Color</label>
<div class="color-row"> <div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" /> <input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" /> <input v-model="color" class="so-input color-hex" placeholder="#7c3aed" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button> <button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button>
</div> </div>
</div> </div>
@@ -357,13 +440,19 @@ async function doDelete() {
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); } .so-input:focus { outline: none; border-color: var(--color-primary); }
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; } .so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; } .dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; } .dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; } .dt-time { width: 7.5rem; flex-shrink: 0; }
.so-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-accent-warm, #d4a017);
font-style: italic;
}
.toggle-btn { .toggle-btn {
background: var(--color-input-bg, #111113); background: var(--color-input-bg, #111113);
@@ -376,8 +465,8 @@ async function doDelete() {
transition: all 0.15s; transition: all 0.15s;
} }
.toggle-btn.active { .toggle-btn.active {
background: var(--color-primary, #6366f1); background: var(--color-primary);
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
color: #fff; color: #fff;
} }
@@ -403,7 +492,7 @@ async function doDelete() {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -94,10 +94,10 @@ const markers: Record<DiffLine["type"], string> = {
/* ── Streaming ── */ /* ── Streaming ── */
.iap-streaming { .iap-streaming {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
} }
.iap-streaming .iap-header { .iap-streaming .iap-header {
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, var(--color-bg-secondary)); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
} }
.iap-pulse { .iap-pulse {
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite; animation: iap-pulse 1.2s ease-in-out infinite;
} }
@@ -172,8 +172,8 @@ const markers: Record<DiffLine["type"], string> = {
font-family: inherit; font-family: inherit;
} }
.iap-btn-toggle:hover { .iap-btn-toggle:hover {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
color: var(--color-primary, #6366f1); color: var(--color-primary);
} }
.iap-actions { .iap-actions {
@@ -78,6 +78,7 @@ const groups = [
:class="['md-btn', { active: btn.isActive() }]" :class="['md-btn', { active: btn.isActive() }]"
:title="btn.title" :title="btn.title"
type="button" type="button"
tabindex="-1"
@mousedown.prevent="btn.command()" @mousedown.prevent="btn.command()"
> >
<span class="btn-icon" v-html="ICONS[btn.id]" /> <span class="btn-icon" v-html="ICONS[btn.id]" />
+3 -3
View File
@@ -64,11 +64,11 @@ function goEdit() {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
background: var(--color-bg-card); background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease; transition: box-shadow 0.2s, transform 0.18s ease;
} }
.note-card:hover { .note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -89,7 +89,7 @@ function goEdit() {
} }
.note-card.compact:hover { .note-card.compact:hover {
box-shadow: none; box-shadow: none;
background: rgba(99, 102, 241, 0.04); background: rgba(124, 58, 237, 0.04);
transform: none; transform: none;
} }
.note-title-compact { .note-title-compact {
+1 -1
View File
@@ -337,7 +337,7 @@ onMounted(async () => {
.btn-add-share { .btn-add-share {
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
+1 -1
View File
@@ -40,7 +40,7 @@ defineEmits<{
} }
.tag-pill:hover { .tag-pill:hover {
color: var(--color-primary); color: var(--color-primary);
background: rgba(99, 102, 241, 0.08); background: var(--color-primary-tint);
} }
.dismiss { .dismiss {
background: none; background: none;
+2 -2
View File
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
background: var(--color-bg-card); background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease; transition: box-shadow 0.2s, transform 0.18s ease;
} }
.task-card:hover { .task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
+4 -4
View File
@@ -751,7 +751,7 @@ function closeEventSlideOver(changed = false) {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -773,14 +773,14 @@ function closeEventSlideOver(changed = false) {
} }
.tool-event-card.clickable { cursor: pointer; } .tool-event-card.clickable { cursor: pointer; }
.tool-event-card.clickable:hover { .tool-event-card.clickable:hover {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card, #16161a));
} }
.tool-event-card-dot { .tool-event-card-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 3px; margin-top: 3px;
} }
@@ -22,6 +22,9 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
function start(stream: MediaStream, onSilence: () => void): void { function start(stream: MediaStream, onSilence: () => void): void {
stop() stop()
audioCtx = new AudioContext() audioCtx = new AudioContext()
// Some browsers start AudioContext in "suspended" state — resume so
// getByteFrequencyData returns real values instead of all zeros.
audioCtx.resume().catch(() => {})
const source = audioCtx.createMediaStreamSource(stream) const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser() const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256 analyser.fftSize = 256
+2 -4
View File
@@ -45,8 +45,7 @@ const router = createRouter({
}, },
{ {
path: "/notes", path: "/notes",
name: "notes", redirect: "/",
component: () => import("@/views/NotesListView.vue"),
}, },
{ {
path: "/notes/new", path: "/notes/new",
@@ -85,8 +84,7 @@ const router = createRouter({
}, },
{ {
path: "/tasks", path: "/tasks",
name: "tasks", redirect: "/",
component: () => import("@/views/TasksListView.vue"),
}, },
{ {
path: "/tasks/new", path: "/tasks/new",
+34 -2
View File
@@ -60,6 +60,27 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const weatherData = ref<WeatherData[]>([]) const weatherData = ref<WeatherData[]>([])
const tempUnit = ref<string>('C') const tempUnit = ref<string>('C')
interface CurrentConditions {
temperature: number | null;
windspeed: number | null;
description: string;
precip_next_3h: number[];
temp_unit: string;
location: string;
}
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
// Patch the live temperature into the WeatherCard so it stays fresh
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent — endpoint may not have locations configured */ }
}
async function loadWeather() { async function loadWeather() {
try { try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather') const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
@@ -90,6 +111,7 @@ async function loadAll() {
getBriefingToday().catch(() => null), getBriefingToday().catch(() => null),
loadWeather(), loadWeather(),
loadNews(), loadNews(),
loadCurrentConditions(),
]) ])
conversations.value = convList conversations.value = convList
if (today) { if (today) {
@@ -198,11 +220,18 @@ useBackgroundRefresh(
) )
let _mounted = true let _mounted = true
onUnmounted(() => { _mounted = false }) onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => { onMounted(async () => {
await checkSetup() await checkSetup()
if (!showWizard.value) await loadAll() if (!showWizard.value) {
await loadAll()
// Poll current conditions every 30 minutes
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
}
}) })
</script> </script>
@@ -235,6 +264,7 @@ onMounted(async () => {
<!-- Left column: Weather --> <!-- Left column: Weather -->
<div class="briefing-left"> <div class="briefing-left">
<div class="panel-label">Weather</div> <div class="panel-label">Weather</div>
<!-- Forecast card -->
<template v-if="weatherData.length"> <template v-if="weatherData.length">
<WeatherCard <WeatherCard
v-for="loc in weatherData" v-for="loc in weatherData"
@@ -457,6 +487,8 @@ onMounted(async () => {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* ── Current conditions (live) ─────────────────────────── */
.panel-empty { .panel-empty {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--color-text-muted); color: var(--color-text-muted);
+10 -10
View File
@@ -435,7 +435,7 @@ const upcomingGrouped = computed(() => {
} }
.btn-new-event { .btn-new-event {
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -482,8 +482,8 @@ const upcomingGrouped = computed(() => {
} }
:deep(.fc-button:hover), :deep(.fc-button:hover),
:deep(.fc-button-active) { :deep(.fc-button-active) {
background: var(--color-primary, #6366f1) !important; background: var(--color-primary) !important;
border-color: var(--color-primary, #6366f1) !important; border-color: var(--color-primary) !important;
color: #fff !important; color: #fff !important;
} }
:deep(.fc-button:focus) { box-shadow: none !important; } :deep(.fc-button:focus) { box-shadow: none !important; }
@@ -494,7 +494,7 @@ const upcomingGrouped = computed(() => {
font-size: 0.82rem; font-size: 0.82rem;
} }
:deep(.fc-daygrid-day.fc-day-today) { :deep(.fc-daygrid-day.fc-day-today) {
background: rgba(99, 102, 241, 0.08); background: var(--color-primary-tint);
} }
:deep(.fc-event) { :deep(.fc-event) {
border-radius: 4px; border-radius: 4px;
@@ -505,7 +505,7 @@ const upcomingGrouped = computed(() => {
} }
:deep(.fc-event-main) { color: #fff; } :deep(.fc-event-main) { color: #fff; }
:deep(.fc-event:not([style*="background"])) { :deep(.fc-event:not([style*="background"])) {
background: var(--color-primary, #6366f1); background: var(--color-primary);
} }
:deep(.fc-daygrid-day-frame) { min-height: 5rem; } :deep(.fc-daygrid-day-frame) { min-height: 5rem; }
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); } :deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
@@ -574,8 +574,8 @@ const upcomingGrouped = computed(() => {
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
} }
.picker-month.active { .picker-month.active {
background: rgba(99,102,241,0.22); background: rgba(124,58,237,0.22);
color: var(--color-primary, #818cf8); color: var(--color-primary);
font-weight: 700; font-weight: 700;
} }
@@ -634,7 +634,7 @@ const upcomingGrouped = computed(() => {
.upcoming-card-accent { .upcoming-card-accent {
width: 4px; width: 4px;
flex-shrink: 0; flex-shrink: 0;
background: var(--ev-color, #6366f1); background: var(--ev-color, #7c3aed);
} }
.upcoming-card-body { .upcoming-card-body {
@@ -691,7 +691,7 @@ const upcomingGrouped = computed(() => {
.popover-accent { .popover-accent {
height: 4px; height: 4px;
background: var(--color-primary, #6366f1); background: var(--color-primary);
} }
.popover-content { .popover-content {
@@ -750,7 +750,7 @@ const upcomingGrouped = computed(() => {
} }
.popover-btn:hover { opacity: 0.85; } .popover-btn:hover { opacity: 0.85; }
.popover-btn--edit { .popover-btn--edit {
background: var(--color-primary, #6366f1); background: var(--color-primary);
color: #fff; color: #fff;
} }
.popover-btn--close { .popover-btn--close {
+9 -9
View File
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
box-shadow: 0 2px 16px rgba(99, 102, 241, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
} }
.hero-top { .hero-top {
display: flex; display: flex;
@@ -605,20 +605,20 @@ function formatUpcomingTime(event: EventEntry): string {
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
padding: 0.55rem 1.1rem; padding: 0.55rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.3); box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
transition: opacity 0.15s, box-shadow 0.15s; transition: opacity 0.15s, box-shadow 0.15s;
flex-shrink: 0; flex-shrink: 0;
} }
.btn-workspace-hero:hover { .btn-workspace-hero:hover {
opacity: 0.9; opacity: 0.9;
box-shadow: 0 3px 14px rgba(99, 102, 241, 0.45); box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
} }
.hero-milestones { margin-bottom: 0.75rem; } .hero-milestones { margin-bottom: 0.75rem; }
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
font-weight: 500; font-weight: 500;
} }
.urgency-in-progress { .urgency-in-progress {
background: color-mix(in srgb, #6366f1 15%, transparent); background: color-mix(in srgb, #7c3aed 15%, transparent);
color: #6366f1; color: #7c3aed;
} }
.urgency-todo { .urgency-todo {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
@@ -892,14 +892,14 @@ function formatUpcomingTime(event: EventEntry): string {
width: 100%; width: 100%;
} }
.upcoming-event-card:hover { .upcoming-event-card:hover {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
} }
.upcoming-event-dot { .upcoming-event-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 4px; margin-top: 4px;
} }
+250 -75
View File
@@ -15,7 +15,7 @@ const chatStore = useChatStore();
interface KnowledgeItem { interface KnowledgeItem {
id: number; id: number;
note_type: "note" | "person" | "place" | "list"; note_type: "note" | "person" | "place" | "list" | "task";
title: string; title: string;
snippet: string; snippet: string;
tags: string[]; tags: string[];
@@ -27,12 +27,20 @@ interface KnowledgeItem {
relationship?: string; relationship?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthday?: string;
organization?: string;
address?: string; address?: string;
hours?: string; hours?: string;
website?: string;
category?: string;
item_count?: number; item_count?: number;
checked_count?: number; checked_count?: number;
list_items?: { text: string; checked: boolean }[]; list_items?: { text: string; checked: boolean }[];
body?: string; body?: string;
// Task-specific
status?: string;
priority?: string;
due_date?: string;
} }
interface UpcomingEvent { interface UpcomingEvent {
@@ -44,7 +52,7 @@ interface UpcomingEvent {
// ─── Filter state ───────────────────────────────────────────────────────────── // ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "person" | "place" | "list">(""); const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
const activeTag = ref(""); const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified"); const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref(""); const searchQuery = ref("");
@@ -69,7 +77,18 @@ const newNoteMenuOpen = ref(false);
function createNew(type: string) { function createNew(type: string) {
newNoteMenuOpen.value = false; newNoteMenuOpen.value = false;
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`); if (type === "task") {
router.push("/tasks/new");
} else {
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
}
}
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
} }
// ─── Two-tier pagination ────────────────────────────────────────────────────── // ─── Two-tier pagination ──────────────────────────────────────────────────────
@@ -307,8 +326,17 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
// ─── Navigation helpers ─────────────────────────────────────────────────────── // ─── Navigation helpers ───────────────────────────────────────────────────────
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
function openItem(item: KnowledgeItem) { function openItem(item: KnowledgeItem) {
router.push(`/notes/${item.id}`); if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@@ -344,6 +372,7 @@ function setupObserver() {
// ─── Lifecycle ──────────────────────────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', onClickOutsideNewNote);
await reset(); await reset();
fetchTags(); fetchTags();
fetchCounts(); fetchCounts();
@@ -353,6 +382,7 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', onClickOutsideNewNote);
if (searchDebounce) clearTimeout(searchDebounce); if (searchDebounce) clearTimeout(searchDebounce);
observer?.disconnect(); observer?.disconnect();
}); });
@@ -392,13 +422,30 @@ onUnmounted(() => {
<aside class="filter-panel"> <aside class="filter-panel">
<!-- New note button --> <!-- New note button -->
<div class="new-note-wrap"> <div class="new-note-wrap">
<button class="btn-new-note" @click="createNew('note')">+ New note</button> <button class="btn-new-note" @click="newNoteMenuOpen = !newNoteMenuOpen">
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button> <span class="btn-new-icon">+</span> New
</button>
<div v-if="newNoteMenuOpen" class="new-note-menu"> <div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">Note</button> <button @click="createNew('note')">
<button @click="createNew('person')">Person</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<button @click="createNew('place')">Place</button> Note
<button @click="createNew('list')">List</button> </button>
<button @click="createNew('task')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Task
</button>
<button @click="createNew('person')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Person
</button>
<button @click="createNew('place')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
Place
</button>
<button @click="createNew('list')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
List
</button>
</div> </div>
</div> </div>
@@ -413,11 +460,11 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span> <span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button> </button>
<button <button
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])" v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
:key="val" :key="val"
class="filter-btn" class="filter-btn"
:class="{ active: activeType === val }" :class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')" @click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
> >
<span class="filter-btn-label">{{ label }}</span> <span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span> <span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
@@ -474,9 +521,8 @@ onUnmounted(() => {
<!-- Loading / empty --> <!-- Loading / empty -->
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading</div> <div v-if="loading && items.length === 0" class="knowledge-empty">Loading</div>
<div v-else-if="!loading && items.length === 0" class="knowledge-empty"> <div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p>Nothing here yet.</p> <p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p> <p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
</div> </div>
<!-- Card grid --> <!-- Card grid -->
@@ -502,11 +548,13 @@ onUnmounted(() => {
<!-- Person specifics --> <!-- Person specifics -->
<div v-if="item.note_type === 'person'" class="k-card-meta"> <div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span> <span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span> <span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div> </div>
<!-- Place specifics --> <!-- Place specifics -->
<div v-else-if="item.note_type === 'place'" class="k-card-meta"> <div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span> <span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span> <span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div> </div>
@@ -533,6 +581,26 @@ onUnmounted(() => {
</span> </span>
</div> </div>
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet --> <!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p> <p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div> </div>
@@ -652,17 +720,17 @@ onUnmounted(() => {
gap: 5px; gap: 5px;
padding: 3px 10px; padding: 3px 10px;
border-radius: 20px; border-radius: 20px;
background: rgba(99, 102, 241, 0.1); background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2); border: 1px solid rgba(124, 58, 237, 0.2);
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
transition: background 0.15s; transition: background 0.15s;
} }
.today-event-chip:hover { background: rgba(99, 102, 241, 0.18); } .today-event-chip:hover { background: rgba(124, 58, 237, 0.18); }
.chip-dot { .chip-dot {
width: 6px; height: 6px; width: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: #6366f1; background: #7c3aed;
flex-shrink: 0; flex-shrink: 0;
} }
.chip-date { color: var(--color-muted); font-size: 0.78rem; } .chip-date { color: var(--color-muted); font-size: 0.78rem; }
@@ -677,7 +745,7 @@ onUnmounted(() => {
font-size: 0.78rem; font-size: 0.78rem;
} }
.today-link { .today-link {
color: var(--color-primary, #6366f1); color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
opacity: 0.85; opacity: 0.85;
@@ -703,73 +771,89 @@ onUnmounted(() => {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
} }
.filter-section { margin-bottom: 20px; } .filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(124, 58, 237, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
.filter-label { .filter-label {
font-size: 0.7rem; font-family: 'Fraunces', Georgia, serif;
text-transform: uppercase; font-style: italic;
letter-spacing: 0.08em; font-size: 0.72rem;
color: var(--color-muted); letter-spacing: 0.02em;
color: var(--color-primary);
margin-bottom: 6px; margin-bottom: 6px;
padding: 0 4px; padding: 0 4px;
} }
/* ── New note button ─────────────────────────────────────── */ /* ── New item button ─────────────────────────────────────── */
.new-note-wrap { .new-note-wrap {
position: relative; position: relative;
display: flex;
margin-bottom: 16px; margin-bottom: 16px;
} }
.btn-new-note { .btn-new-note {
flex: 1; width: 100%;
padding: 7px 10px; display: flex;
border-radius: 8px 0 0 8px; align-items: center;
border: 1px solid rgba(99, 102, 241, 0.4); gap: 6px;
border-right: none; padding: 8px 12px;
background: rgba(99, 102, 241, 0.12); border-radius: 10px;
color: var(--color-primary, #818cf8); border: none;
background: var(--gradient-cta);
color: #fff;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 600;
text-align: left; transition: box-shadow 0.15s;
transition: background 0.15s;
} }
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); } .btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
.btn-new-chevron { .btn-new-icon {
padding: 7px 9px; font-size: 1.1rem;
border-radius: 0 8px 8px 0;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.78rem;
line-height: 1; line-height: 1;
transition: background 0.15s, transform 0.15s; font-weight: 300;
} }
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }
.new-note-menu { .new-note-menu {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 6px);
left: 0; left: 0;
right: 0; right: 0;
background: var(--color-bg-tertiary, #1a1b1e); background: var(--color-bg-card);
border: 1px solid rgba(99, 102, 241, 0.3); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 10px;
overflow: hidden; overflow: hidden;
z-index: 50; z-index: 50;
box-shadow: 0 4px 20px rgba(0,0,0,0.4); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
padding: 4px 0;
} }
.new-note-menu button { .new-note-menu button {
display: block; display: flex;
align-items: center;
gap: 10px;
width: 100%; width: 100%;
padding: 7px 12px; padding: 9px 14px;
background: none; background: none;
border: none; border: none;
color: var(--color-text); color: var(--color-text);
cursor: pointer; cursor: pointer;
font-size: 0.84rem; font-size: 0.84rem;
text-align: left; text-align: left;
transition: background 0.12s; transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--color-primary-tint);
color: var(--color-primary);
}
.new-note-menu button svg {
flex-shrink: 0;
opacity: 0.6;
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--color-primary);
} }
.new-note-menu button:hover { background: rgba(99, 102, 241, 0.12); }
.filter-btn { .filter-btn {
display: flex; display: flex;
@@ -790,8 +874,8 @@ onUnmounted(() => {
} }
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; } .filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
.filter-btn.active { .filter-btn.active {
background: rgba(99, 102, 241, 0.15); background: var(--color-primary-wash);
color: var(--color-primary, #818cf8); color: var(--color-primary);
opacity: 1; opacity: 1;
} }
.filter-btn-label { flex: 1; } .filter-btn-label { flex: 1; }
@@ -807,8 +891,8 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.filter-btn.active .filter-count { .filter-btn.active .filter-count {
background: rgba(99, 102, 241, 0.2); background: rgba(124, 58, 237, 0.2);
color: var(--color-primary, #818cf8); color: var(--color-primary);
} }
.filter-tag { font-size: 0.78rem; } .filter-tag { font-size: 0.78rem; }
@@ -853,7 +937,7 @@ onUnmounted(() => {
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.search-input:focus { border-color: var(--color-primary, #6366f1); } .search-input:focus { border-color: var(--color-primary); }
.sort-select { .sort-select {
padding: 7px 10px; padding: 7px 10px;
border-radius: 8px; border-radius: 8px;
@@ -880,9 +964,9 @@ onUnmounted(() => {
} }
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); } .btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
.btn-graph.active { .btn-graph.active {
background: rgba(99, 102, 241, 0.15); background: var(--color-primary-wash);
border-color: rgba(99, 102, 241, 0.35); border-color: rgba(124, 58, 237, 0.35);
color: var(--color-primary, #818cf8); color: var(--color-primary);
} }
/* ── Card grid ───────────────────────────────────────────── */ /* ── Card grid ───────────────────────────────────────────── */
@@ -890,6 +974,8 @@ onUnmounted(() => {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 16px 20px; padding: 16px 20px;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px; gap: 12px;
@@ -911,16 +997,56 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
} }
.k-card:hover { .k-card:hover {
border-color: rgba(255,255,255,0.14); transform: translateY(-2px);
transform: translateY(-1px); box-shadow: 0 8px 28px rgba(124, 58, 237, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
box-shadow: 0 4px 16px rgba(0,0,0,0.2); border-color: rgba(124, 58, 237, 0.35);
} }
/* Type accent strip */ /* Type-specific card DNA */
.k-card--person { border-left: 3px solid #10b981; } .k-card--note { border-color: rgba(124, 58, 237, 0.20); }
.k-card--place { border-left: 3px solid #f59e0b; } .k-card--task { border-color: rgba(212, 160, 23, 0.18); }
.k-card--list { border-left: 3px solid #38bdf8; } .k-card--person { border-color: rgba(16, 185, 129, 0.18); }
.k-card--note { border-left: 3px solid #6366f1; } .k-card--place { border-color: rgba(245, 158, 11, 0.18); }
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before,
.k-card--list::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #7c3aed, #a78bfa);
}
.k-card--task::before {
width: 50%;
background: linear-gradient(90deg, #d4a017, transparent);
}
.k-card--list::before {
right: 0;
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
}
/* Corner accents for entity types */
.k-card--person::after,
.k-card--place::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 60px;
border-radius: 0 14px 0 60px;
pointer-events: none;
}
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
/* Type badge */ /* Type badge */
.type-badge { .type-badge {
@@ -934,10 +1060,11 @@ onUnmounted(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.badge--note { background: rgba(99,102,241,0.15); color: #818cf8; } .badge--note { background: rgba(99,102,241,0.15); color: #a78bfa; }
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; } .badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; } .badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; } .badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
.k-card-body { flex: 1; padding-right: 40px; } .k-card-body { flex: 1; padding-right: 40px; }
.k-card-title { .k-card-title {
@@ -1038,7 +1165,48 @@ onUnmounted(() => {
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
color: var(--color-muted); color: var(--color-muted);
} }
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; } .k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
/* ── Task card ──────────────────────────────────────────── */
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-accent-warm);
}
.task-overdue {
color: var(--color-overdue);
font-weight: 500;
}
/* ── Empty / loading ─────────────────────────────────────── */ /* ── Empty / loading ─────────────────────────────────────── */
.knowledge-empty { .knowledge-empty {
@@ -1053,6 +1221,13 @@ onUnmounted(() => {
gap: 6px; gap: 6px;
} }
.empty-hint { font-size: 0.85rem; opacity: 0.7; } .empty-hint { font-size: 0.85rem; opacity: 0.7; }
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 1rem;
color: var(--color-accent-warm);
opacity: 0.85;
}
/* ── Sentinel ────────────────────────────────────────────── */ /* ── Sentinel ────────────────────────────────────────────── */
.scroll-sentinel { .scroll-sentinel {
+403 -71
View File
@@ -38,6 +38,81 @@ const dirty = ref(false);
const saving = ref(false); const saving = ref(false);
const showPreview = ref(false); const showPreview = ref(false);
const sidebarOpen = ref(true); const sidebarOpen = ref(true);
const notesExpanded = ref(false);
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(_index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null); const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null); const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => { const tiptapEditor = computed<Editor | null>(() => {
@@ -49,6 +124,15 @@ const noteId = computed(() =>
); );
const isEditing = computed(() => noteId.value !== null); const isEditing = computed(() => noteId.value !== null);
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
const renderedPreview = computed(() => renderMarkdown(body.value)); const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist — pass noteId for draft persistence // AI Assist — pass noteId for draft persistence
@@ -219,6 +303,13 @@ onMounted(async () => {
milestoneId.value = store.currentNote.milestone_id ?? null; milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note"; noteType.value = (store.currentNote.note_type as NoteType) || "note";
Object.assign(entityMeta, store.currentNote.metadata || {}); Object.assign(entityMeta, store.currentNote.metadata || {});
notesExpanded.value = !!(store.currentNote.body || '').trim();
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value]; savedTags = [...tags.value];
@@ -233,6 +324,9 @@ onMounted(async () => {
if (qt && ["note", "person", "place", "list"].includes(qt)) { if (qt && ["note", "person", "place", "list"].includes(qt)) {
noteType.value = qt as NoteType; noteType.value = qt as NoteType;
} }
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
} }
// Restore pending draft if any // Restore pending draft if any
@@ -243,6 +337,9 @@ onMounted(async () => {
// No draft — normal // No draft — normal
} }
await nextTick();
titleRef.value?.focus();
// Initial link suggestions // Initial link suggestions
fetchLinkSuggestions(); fetchLinkSuggestions();
}); });
@@ -250,11 +347,12 @@ onMounted(async () => {
async function save() { async function save() {
if (saving.value) return; if (saving.value) return;
saving.value = true; saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
try { try {
if (isEditing.value) { if (isEditing.value) {
await store.updateNote(noteId.value!, { await store.updateNote(noteId.value!, {
title: title.value, title: title.value,
body: body.value, body: finalBody,
tags: tags.value, tags: tags.value,
project_id: projectId.value, project_id: projectId.value,
milestone_id: milestoneId.value, milestone_id: milestoneId.value,
@@ -273,7 +371,7 @@ async function save() {
} else { } else {
const note = await store.createNote({ const note = await store.createNote({
title: title.value, title: title.value,
body: body.value, body: finalBody,
tags: tags.value, tags: tags.value,
project_id: projectId.value, project_id: projectId.value,
milestone_id: milestoneId.value, milestone_id: milestoneId.value,
@@ -304,7 +402,7 @@ async function confirmDelete() {
await store.deleteNote(noteId.value); await store.deleteNote(noteId.value);
dirty.value = false; dirty.value = false;
toast.show("Note deleted"); toast.show("Note deleted");
router.push("/notes"); router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch { } catch {
toast.show("Failed to delete note", "error"); toast.show("Failed to delete note", "error");
} }
@@ -314,9 +412,10 @@ async function confirmDelete() {
async function doAutoSave() { async function doAutoSave() {
if (!isEditing.value || saving.value) return; if (!isEditing.value || saving.value) return;
saving.value = true; saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
try { try {
await store.updateNote(noteId.value!, { await store.updateNote(noteId.value!, {
title: title.value, body: body.value, tags: tags.value, title: title.value, body: finalBody, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value, project_id: projectId.value, milestone_id: milestoneId.value,
note_type: noteType.value, metadata: { ...entityMeta }, note_type: noteType.value, metadata: { ...entityMeta },
}); });
@@ -346,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
<main class="editor-page note-editor-page"> <main class="editor-page note-editor-page">
<div class="editor-header"> <div class="editor-header">
<div class="toolbar"> <div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link> <router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving"> <button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
</button> </button>
@@ -357,7 +456,7 @@ onUnmounted(() => assist.clearSelection());
ref="titleRef" ref="titleRef"
v-model="title" v-model="title"
type="text" type="text"
placeholder="Title" :placeholder="titlePlaceholder"
class="title-input" class="title-input"
@input="markDirty" @input="markDirty"
@keydown.ctrl.s.prevent="save" @keydown.ctrl.s.prevent="save"
@@ -370,48 +469,181 @@ onUnmounted(() => assist.clearSelection());
<!-- Main column --> <!-- Main column -->
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"> <div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row"> <!-- Person form -->
<div class="editor-tabs"> <template v-if="noteType === 'person'">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button> <div class="entity-form">
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button> <div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div> </div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" /> <div class="notes-section">
</div> <button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
<!-- Streaming preview --> </button>
<template v-if="assist.state.value === 'streaming'"> <div v-if="notesExpanded" class="notes-editor-wrap">
<div class="stream-label">Generating...</div> <MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<TiptapEditor <TiptapEditor
ref="editorRef" ref="editorRef"
:modelValue="body" :modelValue="body"
placeholder="Write your note in Markdown..." placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate" @update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()" @escape="titleRef?.focus()"
/> />
</div> </div>
<div </div>
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</template> </template>
<!-- Error from assist --> <!-- Place form -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div> <template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- List builder -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- Generic note editor -->
<template v-else>
<div class="body-tabs-row">
<div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</template>
<!-- Error from assist -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
</template>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
@@ -476,38 +708,6 @@ onUnmounted(() => assist.clearSelection());
</select> </select>
</div> </div>
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
<!-- Link Suggestions --> <!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field"> <div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row"> <div class="sb-label-row">
@@ -731,7 +931,7 @@ onUnmounted(() => assist.clearSelection());
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.sb-select:focus, .sb-input:focus { .sb-select:focus, .sb-input:focus {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
} }
/* Tag suggest row inside sidebar */ /* Tag suggest row inside sidebar */
@@ -819,6 +1019,138 @@ onUnmounted(() => assist.clearSelection());
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow screen: sidebar collapses */ /* Narrow screen: sidebar collapses */
@media (max-width: 720px) { @media (max-width: 720px) {
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; } .note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+3 -3
View File
@@ -350,17 +350,17 @@ async function convertToTask() {
padding: 0.45rem 1.1rem; padding: 0.45rem 1.1rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-edit:hover { .btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); box-shadow: var(--glow-cta-hover);
opacity: 0.95; opacity: 0.95;
color: #fff; color: #fff;
} }
-376
View File
@@ -1,376 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
type ViewMode = "grid" | "list";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
function onFocusSearch() {
searchBarRef.value?.focus();
}
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.notes),
(note) => router.push(`/notes/${note.id}`),
);
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
);
function setViewMode(mode: ViewMode) {
viewMode.value = mode;
localStorage.setItem("fabled-notes-view-mode", mode);
}
onMounted(() => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
} else {
store.refresh();
}
document.addEventListener("shortcut:focus-search", onFocusSearch);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
</script>
<template>
<main class="notes-list">
<div class="header">
<h1>Notes</h1>
<router-link to="/notes/new" class="btn-new">+ New Note</router-link>
</div>
<SearchBar ref="searchBarRef" @search="onSearch" />
<div class="controls">
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
</button>
</div>
<!-- View mode toggle -->
<div class="view-toggle">
<button
:class="['toggle-btn', { active: viewMode === 'grid' }]"
title="Grid view"
@click="setViewMode('grid')"
></button>
<button
:class="['toggle-btn', { active: viewMode === 'list' }]"
title="Compact list"
@click="setViewMode('list')"
></button>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<div v-if="store.loading" class="note-list-skeleton">
<div v-if="viewMode === 'grid'" class="skeleton-grid">
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
</div>
<div v-else class="skeleton-rows">
<div class="skeleton-row" v-for="i in 8" :key="i"></div>
</div>
</div>
<div v-else-if="store.notes.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length">
<p class="empty-title">No notes match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No notes yet</p>
<p class="empty-subtitle">Create your first note to get started.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</template>
</div>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="cards-grid">
<div
v-for="(note, i) in store.notes"
:key="note.id"
:class="{ 'kb-active-item': activeIndex === i }"
>
<NoteCard :note="note" @tag-click="onTagClick" />
</div>
</div>
<!-- Compact list view -->
<div v-else class="cards-list">
<div
v-for="(note, i) in store.notes"
:key="note.id"
:class="{ 'kb-active-item': activeIndex === i }"
>
<NoteCard :note="note" compact @tag-click="onTagClick" />
</div>
</div>
<PaginationBar
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</main>
</template>
<style scoped>
.notes-list {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-new:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.view-toggle {
display: flex;
gap: 0.2rem;
}
.toggle-btn {
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
/* Skeleton loading */
.note-list-skeleton {
margin-top: 1rem;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
}
.skeleton-card {
height: 140px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.skeleton-row {
height: 40px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Grid layout */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.cards-grid > * {
min-width: 0;
}
/* Compact list layout */
.cards-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 1rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-cta:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.kb-active-item {
outline: 2px solid var(--color-primary);
border-radius: var(--radius-md);
}
</style>
+52 -3
View File
@@ -20,7 +20,7 @@ interface Project {
title: string; title: string;
description: string | null; description: string | null;
goal: string | null; goal: string | null;
status: "active" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null; auto_summary: string | null;
permission?: string; permission?: string;
@@ -40,7 +40,7 @@ const toast = useToastStore();
const projects = ref<Project[]>([]); const projects = ref<Project[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all"); const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
// New project modal // New project modal
const showNewProjectModal = ref(false); const showNewProjectModal = ref(false);
@@ -103,6 +103,7 @@ async function createProject() {
function statusLabel(status: Project["status"]): string { function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active"; if (status === "active") return "Active";
if (status === "paused") return "Paused";
if (status === "completed") return "Completed"; if (status === "completed") return "Completed";
if (status === "archived") return "Archived"; if (status === "archived") return "Archived";
return status; return status;
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
if (!text) return ""; if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text; return text.length > max ? text.slice(0, max) + "..." : text;
} }
function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 };
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
return { total, pct };
}
</script> </script>
<template> <template>
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
<!-- Filter tabs --> <!-- Filter tabs -->
<div class="filter-tabs"> <div class="filter-tabs">
<button <button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const" v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
:key="tab" :key="tab"
:class="['tab-btn', { active: activeTab === tab }]" :class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab" @click="activeTab = tab"
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
</p> </p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p> <p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Overall completion bar -->
<div
v-if="overallPct(project).total > 0"
class="overall-bar-row"
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
>
<div class="overall-bar-track">
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
</div>
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
</div>
<!-- Milestone progress bars --> <!-- Milestone progress bars -->
<div <div
v-if="project.summary?.milestone_summary?.length" v-if="project.summary?.milestone_summary?.length"
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
line-height: 1.45; line-height: 1.45;
} }
.overall-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
margin-top: 0.1rem;
}
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--color-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
font-weight: 600;
}
.milestone-bars { .milestone-bars {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+19 -7
View File
@@ -23,7 +23,7 @@ interface Project {
title: string; title: string;
description: string | null; description: string | null;
goal: string | null; goal: string | null;
status: "active" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null; auto_summary: string | null;
permission?: string; permission?: string;
@@ -118,6 +118,14 @@ function toggleMilestoneCollapse(id: number) {
} }
} }
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
}
}
async function loadProject() { async function loadProject() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -130,6 +138,7 @@ async function loadProject() {
editStatus.value = data.status; editStatus.value = data.status;
editDirty.value = false; editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? []; milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
} catch { } catch {
error.value = "Failed to load project."; error.value = "Failed to load project.";
} finally { } finally {
@@ -143,6 +152,7 @@ async function loadMilestones() {
`/api/projects/${projectId.value}/milestones` `/api/projects/${projectId.value}/milestones`
); );
milestones.value = data.milestones; milestones.value = data.milestones;
autoCollapseCompleted(milestones.value);
} catch { } catch {
// silent // silent
} }
@@ -395,6 +405,7 @@ async function confirmDelete() {
<label class="edit-label">Status</label> <label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select"> <select v-model="editStatus" class="edit-select">
<option value="active">Active</option> <option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
@@ -676,17 +687,17 @@ async function confirmDelete() {
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-workspace:hover { box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); opacity: 0.95; color: #fff; } .btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
.btn-share { .btn-share {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
@@ -752,6 +763,7 @@ async function confirmDelete() {
flex-shrink: 0; flex-shrink: 0;
} }
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); } .status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); } .status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); } .status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
@@ -857,7 +869,7 @@ async function confirmDelete() {
.btn-save-panel { .btn-save-panel {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -866,10 +878,10 @@ async function confirmDelete() {
font-weight: 600; font-weight: 600;
font-family: inherit; font-family: inherit;
width: 100%; width: 100%;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25); box-shadow: var(--glow-soft);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); opacity: 0.95; } .btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); opacity: 0.95; }
.btn-save-panel:disabled { opacity: 0.45; cursor: default; } .btn-save-panel:disabled { opacity: 0.45; cursor: default; }
/* ── Content area ────────────────────────────────────────────── */ /* ── Content area ────────────────────────────────────────────── */
+2 -2
View File
@@ -3024,7 +3024,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
} }
.sidebar-item.active { .sidebar-item.active {
color: var(--color-primary); color: var(--color-primary);
background: rgba(99, 102, 241, 0.08); background: rgba(124, 58, 237, 0.08);
border-left-color: var(--color-primary); border-left-color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }
@@ -3489,7 +3489,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
} }
.sidebar-item.active { .sidebar-item.active {
border-bottom-color: var(--color-primary); border-bottom-color: var(--color-primary);
background: rgba(99, 102, 241, 0.08); background: var(--color-primary-tint);
} }
.settings-grid { .settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+2 -2
View File
@@ -362,7 +362,7 @@ async function confirmDelete() {
await store.deleteTask(taskId.value); await store.deleteTask(taskId.value);
dirty.value = false; dirty.value = false;
toast.show("Task deleted"); toast.show("Task deleted");
router.push("/tasks"); router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch { } catch {
toast.show("Failed to delete task", "error"); toast.show("Failed to delete task", "error");
} }
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
<main class="editor-page task-editor-page"> <main class="editor-page task-editor-page">
<div class="editor-header"> <div class="editor-header">
<div class="toolbar"> <div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link> <router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving"> <button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
</button> </button>
+4 -4
View File
@@ -480,23 +480,23 @@ const subTaskProgress = computed(() => {
padding: 0.45rem 1.1rem; padding: 0.45rem 1.1rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-edit:hover { .btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); box-shadow: var(--glow-cta-hover);
opacity: 0.95; opacity: 0.95;
color: #fff; color: #fff;
} }
.btn-advance { .btn-advance {
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
-678
View File
@@ -1,678 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
type ViewMode = "smart" | "grouped";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
function onFocusSearch() {
searchBarRef.value?.focus();
}
const _storedMode = localStorage.getItem("fabled-tasks-view-mode");
const viewMode = ref<ViewMode>(_storedMode === "grouped" ? "grouped" : "smart");
function setViewMode(mode: ViewMode) {
viewMode.value = mode;
localStorage.setItem("fabled-tasks-view-mode", mode);
store.limit = mode === "grouped" ? 100 : 200;
store.offset = 0;
store.refresh();
}
// Keyboard nav disabled — smart sections span multiple containers
useListKeyboardNavigation(
computed(() => store.tasks),
(task) => router.push(`/tasks/${task.id}`),
".kb-active-item",
computed(() => false),
);
// Project map for group labels and task breadcrumbs
const projectMap = ref<Map<number, string>>(new Map());
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects");
projectMap.value = new Map(data.projects.map((p) => [p.id, p.title]));
} catch {
// non-fatal — labels just won't show
}
}
// Grouped tasks: [{projectId, title, tasks[]}]
interface TaskGroup {
projectId: number | null;
title: string;
tasks: Task[];
}
interface TaskSection {
key: string;
label: string;
tasks: Task[];
}
const smartSections = computed<TaskSection[]>(() => {
if (viewMode.value !== "smart") return [];
const today = new Date().toISOString().slice(0, 10);
const weekEnd = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const overdue: Task[] = [], dueToday: Task[] = [], thisWeek: Task[] = [],
upcoming: Task[] = [], noDueDate: Task[] = [], done: Task[] = [];
for (const task of store.tasks) {
if (task.status === "done") { done.push(task); continue; }
if (!task.due_date) { noDueDate.push(task); continue; }
if (task.due_date < today) { overdue.push(task); continue; }
if (task.due_date === today) { dueToday.push(task); continue; }
if (task.due_date <= weekEnd) { thisWeek.push(task); continue; }
upcoming.push(task);
}
const sections: TaskSection[] = [];
if (overdue.length) sections.push({ key: "overdue", label: "Overdue", tasks: overdue });
if (dueToday.length) sections.push({ key: "today", label: "Due Today", tasks: dueToday });
if (thisWeek.length) sections.push({ key: "week", label: "This Week", tasks: thisWeek });
if (upcoming.length) sections.push({ key: "upcoming", label: "Upcoming", tasks: upcoming });
if (noDueDate.length) sections.push({ key: "no-date", label: "No Due Date", tasks: noDueDate });
if (done.length) sections.push({ key: "done", label: "Completed", tasks: done });
return sections;
});
const groupedTasks = computed<TaskGroup[]>(() => {
if (viewMode.value !== "grouped") return [];
const map = new Map<number | null, Task[]>();
for (const task of store.tasks) {
const key = task.project_id ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(task);
}
const groups: TaskGroup[] = [];
// Named projects first (sorted by title), then "No Project"
for (const [id, tasks] of map) {
if (id !== null) {
groups.push({
projectId: id,
title: projectMap.value.get(id) ?? `Project #${id}`,
tasks,
});
}
}
groups.sort((a, b) => a.title.localeCompare(b.title));
if (map.has(null)) {
groups.push({ projectId: null, title: "No Project", tasks: map.get(null)! });
}
return groups;
});
onMounted(async () => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
}
if (route.query.status) {
const qs = route.query.status;
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
}
store.limit = viewMode.value === "grouped" ? 100 : 200;
collapsedGroups.value.add("done");
await Promise.all([store.refresh(), loadProjects()]);
document.addEventListener("shortcut:focus-search", onFocusSearch);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
store.offset = 0;
store.refresh();
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function toggleStatusChip(value: TaskStatus) {
const current = [...store.statusFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setStatusFilter(current);
}
function togglePriorityChip(value: TaskPriority) {
const current = [...store.priorityFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setPriorityFilter(current);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onStatusToggle(id: number, status: TaskStatus) {
store.patchStatus(id, status);
}
// Collapse state for grouped sections
const collapsedGroups = ref<Set<string>>(new Set());
function toggleGroup(key: string) {
if (collapsedGroups.value.has(key)) {
collapsedGroups.value.delete(key);
} else {
collapsedGroups.value.add(key);
}
}
</script>
<template>
<main class="tasks-list">
<div class="header">
<h1>Tasks</h1>
<router-link to="/tasks/new" class="btn-new">+ New Task</router-link>
</div>
<SearchBar ref="searchBarRef" @search="onSearch" />
<div class="controls">
<div class="filter-controls">
<div class="filter-chip-group">
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
@click="toggleStatusChip(s)">
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
</button>
</div>
<div class="filter-chip-group">
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
@click="togglePriorityChip(p)">
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
</button>
</div>
</div>
<div class="right-controls">
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
<option value="due_date">Due Date</option>
<option value="priority">Priority</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
</button>
</div>
<!-- View mode toggle -->
<div class="view-toggle">
<button
:class="['toggle-btn', { active: viewMode === 'smart' }]"
title="Smart sections (by due date)"
@click="setViewMode('smart')"
></button>
<button
:class="['toggle-btn', { active: viewMode === 'grouped' }]"
title="Group by project"
@click="setViewMode('grouped')"
></button>
</div>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<div v-if="store.loading" class="task-list-skeleton">
<div class="skeleton-row" v-for="i in 6" :key="i"></div>
</div>
<div v-else-if="store.tasks.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<div class="empty-state-rich">
<div class="empty-icon"></div>
<p class="empty-title">No tasks yet</p>
<p class="empty-sub">Create your first task to start tracking work</p>
<router-link to="/tasks/new" class="empty-action">New task </router-link>
</div>
</template>
</div>
<!-- Grouped view -->
<template v-else-if="viewMode === 'grouped'">
<div
v-for="group in groupedTasks"
:key="group.projectId ?? 'none'"
class="task-group"
>
<button
class="group-header"
@click="toggleGroup(String(group.projectId))"
>
<span class="group-chevron">{{ collapsedGroups.has(String(group.projectId)) ? '▶' : '▼' }}</span>
<span class="group-title">{{ group.title }}</span>
<span class="group-count">{{ group.tasks.length }}</span>
<router-link
v-if="group.projectId"
:to="`/projects/${group.projectId}`"
class="group-open-link"
@click.stop
>Open project </router-link>
</button>
<div v-if="!collapsedGroups.has(String(group.projectId))" class="group-tasks">
<TaskCard
v-for="task in group.tasks"
:key="task.id"
:task="task"
compact
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
</div>
</template>
<!-- Smart sections view (by due date) -->
<template v-else>
<div v-for="section in smartSections" :key="section.key" class="task-section">
<button class="section-header" @click="toggleGroup(section.key)">
<span :class="['section-dot', `dot-${section.key}`]"></span>
<span class="section-label">{{ section.label }}</span>
<span class="section-count">{{ section.tasks.length }}</span>
<span class="section-chevron">{{ collapsedGroups.has(section.key) ? "▶" : "▼" }}</span>
</button>
<div v-show="!collapsedGroups.has(section.key)" class="section-tasks">
<TaskCard
v-for="task in section.tasks"
:key="task.id"
:task="task"
compact
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
</div>
</template>
</main>
</template>
<style scoped>
.tasks-list {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-new:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--color-input-border);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.78rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-chip.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.right-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-select,
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.view-toggle {
display: flex;
gap: 0.2rem;
}
.toggle-btn {
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
/* Skeleton loading */
.task-list-skeleton {
margin-top: 1rem;
}
.skeleton-row {
height: 44px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
margin-bottom: 0.3rem;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Smart sections */
.task-section {
margin-top: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
padding: 0.3rem 0;
cursor: pointer;
text-align: left;
color: var(--color-text);
}
.section-header:hover .section-label {
color: var(--color-primary);
}
.section-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-overdue { background: var(--color-danger, #e74c3c); }
.dot-today { background: var(--color-primary); }
.dot-week { background: #f59e0b; }
.dot-upcoming { background: var(--color-text-secondary); }
.dot-no-date { background: var(--color-text-muted); }
.dot-done { background: var(--color-status-done, #22c55e); }
.section-label {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
transition: color 0.15s;
}
.section-count {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.section-chevron {
font-size: 0.65rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.section-tasks {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0 0.25rem;
}
/* Grouped view */
.task-group {
margin-top: 1.25rem;
}
.group-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
padding: 0.3rem 0 0.3rem 0;
cursor: pointer;
text-align: left;
color: var(--color-text);
}
.group-header:hover {
color: var(--color-primary);
}
.group-chevron {
font-size: 0.65rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.group-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
}
.group-count {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.group-open-link {
font-size: 0.75rem;
color: var(--color-primary);
text-decoration: none;
flex-shrink: 0;
}
.group-open-link:hover {
text-decoration: underline;
}
.group-tasks {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
}
.empty-state-rich {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.3;
}
.empty-state-rich .empty-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin: 0 0 0.35rem;
}
.empty-sub {
font-size: 0.85rem;
margin: 0 0 1rem;
}
.empty-action {
display: inline-block;
padding: 0.4rem 1rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
text-decoration: none;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
background: var(--color-primary);
color: #fff;
}
.kb-active-item {
outline: 2px solid var(--color-primary);
border-radius: var(--radius-md);
}
</style>
+141
View File
@@ -155,6 +155,44 @@ async def get_weather():
return jsonify({"locations": cards, "temp_unit": temp_unit}) return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"]) @briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE @_REQUIRE
async def geocode_location(): async def geocode_location():
@@ -506,3 +544,106 @@ async def discuss_article(item_id: int):
)) ))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202 return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
user_prompt,
think=True,
))
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202
+1 -1
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge") knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list"} _VALID_TYPES = {"note", "person", "place", "list", "task"}
_VALID_SORTS = {"modified", "created", "alpha", "type"} _VALID_SORTS = {"modified", "created", "alpha", "type"}
+3 -3
View File
@@ -52,7 +52,7 @@ async def create_project_route():
if not data.get("title"): if not data.get("title"):
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
status = data.get("status", "active") status = data.get("status", "active")
if status not in ("active", "completed", "archived"): if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project( project = await create_project(
uid, uid,
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"} allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed} fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "completed", "archived"): if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields) project = await update_project(uid, project_id, **fields)
if project is None: if project is None:
+414 -33
View File
@@ -23,14 +23,25 @@ SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def format_task(task: dict) -> str: def format_task(task: dict, today: str | None = None) -> str:
parts = [task.get("title", "Untitled")] parts = [task.get("title", "Untitled")]
if task.get("status"): if task.get("status"):
parts.append(f"[{task['status'].replace('_', ' ').title()}]") parts.append(f"[{task['status'].replace('_', ' ').title()}]")
if task.get("due_date"): if task.get("due_date"):
parts.append(f"due {task['due_date']}") parts.append(f"due {task['due_date']}")
if today and task["due_date"] < today and task.get("status") not in ("done", "cancelled"):
from datetime import date
try:
due = date.fromisoformat(task["due_date"])
td = date.fromisoformat(today)
days = (td - due).days
parts.append(f"({days} day{'s' if days != 1 else ''} overdue)")
except ValueError:
pass
if task.get("priority") and task["priority"] not in ("none", ""): if task.get("priority") and task["priority"] not in ("none", ""):
parts.append(f"priority: {task['priority']}") parts.append(f"priority: {task['priority']}")
if task.get("project_title"):
parts.append(f"project: {task['project_title']}")
return "".join(parts) return "".join(parts)
@@ -134,6 +145,13 @@ async def _gather_internal(user_id: int) -> dict:
all_tasks: list[dict] = [] all_tasks: list[dict] = []
try: try:
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100) all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
# Build project title lookup for enrichment
project_titles: dict[int, str] = {}
try:
for p in await list_projects(user_id):
project_titles[p.id] = p.title or "Untitled"
except Exception:
pass
all_tasks = [ all_tasks = [
{ {
"task_id": t.id, "task_id": t.id,
@@ -141,21 +159,22 @@ async def _gather_internal(user_id: int) -> dict:
"status": t.status, "status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None, "due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority, "priority": t.priority,
"project_title": project_titles.get(t.project_id) if t.project_id else None,
} }
for t in all_task_objs for t in all_task_objs
] ]
overdue = [ overdue = [
format_task(t) for t in all_tasks format_task(t, today) for t in all_tasks
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done" if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled")
] ]
due_today = [ due_today = [
format_task(t) for t in all_tasks format_task(t, today) for t in all_tasks
if t.get("due_date") == today and t.get("status") != "done" if t.get("due_date") == today and t.get("status") not in ("done", "cancelled")
] ]
high_priority = [ high_priority = [
format_task(t) for t in all_tasks format_task(t, today) for t in all_tasks
if t.get("priority") == "high" and t.get("status") not in ("done",) and if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and
format_task(t) not in overdue and format_task(t) not in due_today format_task(t, today) not in overdue and format_task(t, today) not in due_today
][:5] ][:5]
except Exception: except Exception:
logger.warning("Failed to gather tasks for briefing", exc_info=True) logger.warning("Failed to gather tasks for briefing", exc_info=True)
@@ -163,6 +182,7 @@ async def _gather_internal(user_id: int) -> dict:
# Calendar events today — internal store # Calendar events today — internal store
calendar_events: list[str] = [] calendar_events: list[str] = []
internal_events: list[dict] = []
try: try:
from fabledassistant.services.events import list_events as list_internal_events from fabledassistant.services.events import list_events as list_internal_events
today_date = datetime.now(user_tz).date() today_date = datetime.now(user_tz).date()
@@ -197,6 +217,87 @@ async def _gather_internal(user_id: int) -> dict:
except Exception: except Exception:
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True) logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
# Weather × calendar cross-reference
weather_warnings: list[str] = []
outdoor_keywords = {"park", "field", "patio", "garden", "outdoor", "trail", "beach",
"pool", "stadium", "yard", "lawn", "hike", "walk", "run", "bike",
"soccer", "baseball", "football", "tennis", "golf", "playground"}
try:
import json as _json
raw_config = await get_setting(user_id, "briefing_config", "{}")
config = _json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
locations = config.get("locations", {})
loc = locations.get("home") or locations.get("work")
if loc and loc.get("lat") and loc.get("lon") and internal_events:
from fabledassistant.services.weather import fetch_hourly_precip
hourly_precip = await fetch_hourly_precip(loc["lat"], loc["lon"])
if hourly_precip:
for e in internal_events:
if e.get("all_day"):
continue
# Check if event title or location contains outdoor keywords
event_text = f"{e.get('title', '')} {e.get('location', '')}".lower()
if not any(kw in event_text for kw in outdoor_keywords):
continue
# Get the event's start hour
start = e.get("start_dt")
if not start:
continue
from datetime import datetime as _dt
if isinstance(start, str):
start = _dt.fromisoformat(start)
local_start = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
# Check precip probability around the event time (hour before through event)
hour_key = local_start.strftime("%Y-%m-%dT%H:00")
precip = hourly_precip.get(hour_key, 0)
if precip >= 30:
time_str = local_start.strftime("%-I:%M %p")
weather_warnings.append(
f"Rain likely ({precip}%) around {time_str} when '{e.get('title', 'Event')}' "
f"is scheduled — consider an umbrella or backup plan"
)
except Exception:
logger.debug("Weather cross-reference failed", exc_info=True)
# Recent activity — what was the user working on yesterday?
recent_activity: list[str] = []
stale_projects: list[str] = []
try:
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
yesterday = datetime.now(user_tz) - __import__('datetime').timedelta(days=1)
async with async_session() as session:
# Notes/tasks updated in last 24h, grouped by project
result = await session.execute(
_sel(Project.title, _func.count(Note.id))
.join(Note, Note.project_id == Project.id)
.where(Note.user_id == user_id)
.where(Note.updated_at >= yesterday)
.group_by(Project.title)
.order_by(_func.count(Note.id).desc())
.limit(3)
)
for proj_title, count in result.all():
recent_activity.append(f"{proj_title}: {count} item{'s' if count != 1 else ''} updated")
# Stale projects — active projects with no note/task activity in 7+ days
week_ago = datetime.now(user_tz) - __import__('datetime').timedelta(days=7)
active_projects_q = await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)
for p in active_projects_q.scalars().all():
latest = await session.execute(
_sel(_func.max(Note.updated_at))
.where(Note.project_id == p.id, Note.user_id == user_id)
)
last_activity = latest.scalar()
if last_activity is None or last_activity < week_ago:
stale_projects.append(p.title or "Untitled")
except Exception:
logger.debug("Failed to gather recent activity for briefing", exc_info=True)
# Projects: active projects # Projects: active projects
projects_summary = [] projects_summary = []
try: try:
@@ -206,6 +307,51 @@ async def _gather_internal(user_id: int) -> dict:
except Exception: except Exception:
logger.warning("Failed to gather projects for briefing", exc_info=True) logger.warning("Failed to gather projects for briefing", exc_info=True)
# Build prioritized "Your Day" focus list
# Priority: overdue > due today > high priority in-progress > other in-progress
focus_tasks: list[str] = []
seen_titles: set[str] = set()
for t in sorted(all_tasks, key=lambda x: (
0 if (x.get("due_date") and x["due_date"] < today and x.get("status") not in ("done", "cancelled")) else
1 if (x.get("due_date") == today and x.get("status") not in ("done", "cancelled")) else
2 if (x.get("priority") == "high" and x.get("status") not in ("done", "cancelled")) else
3 if (x.get("status") == "in_progress") else 4
)):
if t.get("status") in ("done", "cancelled"):
continue
formatted = format_task(t, today)
if formatted not in seen_titles:
seen_titles.add(formatted)
focus_tasks.append(formatted)
if len(focus_tasks) >= 5:
break
# Calendar gap analysis — extract event times for LLM
event_time_blocks: list[str] = []
for e in internal_events:
try:
if e.get("all_day"):
continue
start = e.get("start_dt")
end = e.get("end_dt")
if not start:
continue
from datetime import datetime as _dt
if isinstance(start, str):
start = _dt.fromisoformat(start)
if isinstance(end, str):
end = _dt.fromisoformat(end)
s_local = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
s_str = s_local.strftime("%-I:%M %p")
if end:
e_local = end.astimezone(user_tz) if end.tzinfo else end.replace(tzinfo=timezone.utc).astimezone(user_tz)
e_str = e_local.strftime("%-I:%M %p")
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}{e_str}")
else:
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}?")
except Exception:
pass
return { return {
"date": today, "date": today,
"overdue_tasks": overdue, "overdue_tasks": overdue,
@@ -214,6 +360,80 @@ async def _gather_internal(user_id: int) -> dict:
"calendar_events": calendar_events, "calendar_events": calendar_events,
"active_projects": projects_summary, "active_projects": projects_summary,
"all_tasks_raw": all_tasks, "all_tasks_raw": all_tasks,
"focus_tasks": focus_tasks,
"event_time_blocks": event_time_blocks,
"weather_warnings": weather_warnings,
"recent_activity": recent_activity,
"stale_projects": stale_projects,
}
async def _gather_weekly_review(user_id: int) -> dict:
"""Collect 7-day activity summary for the weekly review."""
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.events import list_events as list_internal_events
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
now = datetime.now(user_tz)
week_ago = now - __import__('datetime').timedelta(days=7)
next_week = now + __import__('datetime').timedelta(days=7)
# Tasks completed this week
completed_tasks = []
overdue_tasks = []
try:
all_task_objs, _ = await list_notes(user_id, is_task=True, limit=200)
for t in all_task_objs:
if t.status == "done" and t.completed_at and t.completed_at >= week_ago:
completed_tasks.append(t.title or "Untitled")
elif t.due_date and t.due_date.isoformat() < now.date().isoformat() and t.status not in ("done", "cancelled"):
overdue_tasks.append(t.title or "Untitled")
except Exception:
logger.debug("Weekly review: failed to gather tasks", exc_info=True)
# Notes created this week
notes_created = 0
try:
all_notes, _ = await list_notes(user_id, is_task=False, limit=200)
notes_created = sum(1 for n in all_notes if n.created_at and n.created_at >= week_ago)
except Exception:
logger.debug("Weekly review: failed to count notes", exc_info=True)
# Project activity
active_projects = []
try:
projects = await list_projects(user_id)
active_projects = [p.title for p in projects if p.status == "active"][:5]
except Exception:
pass
# Events next week
upcoming_events = []
try:
next_events = await list_internal_events(
user_id=user_id,
date_from=now,
date_to=next_week,
)
for e in next_events[:8]:
upcoming_events.append(f"{e.get('title', 'Event')}{e.get('start_dt', '')[:10]}")
except Exception:
pass
return {
"completed_count": len(completed_tasks),
"completed_highlights": completed_tasks[:5],
"overdue_count": len(overdue_tasks),
"overdue_items": overdue_tasks[:5],
"notes_created": notes_created,
"active_projects": active_projects,
"upcoming_events": upcoming_events,
} }
@@ -259,23 +479,107 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_
return "" return ""
def _unified_system_prompt(profile_body: str) -> str: def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str:
return ( base = (
"You are a personal assistant delivering a daily briefing. " "You are a personal assistant. "
"Speak naturally and conversationally — as if talking to the user, not writing a report. " "Speak naturally and conversationally — as if talking to the user, not writing a report. "
"Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. " "Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. "
"Weave together what matters today: mention the weather in a sentence, note any calendar " "Be warm, concise, and human. "
"events or tasks due today, and briefly reference one or two noteworthy news stories. " "The user can reply to act on your suggestions via tool calls.\n\n"
"Only mention projects if a task from one is specifically due today. "
"Be warm, concise, and human — aim for 3 to 5 sentences. "
"Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n"
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
) )
if slot == "compilation":
base += (
"This is the MORNING BRIEFING — the full daily overview. "
"Weave together what matters today: mention the weather, note calendar events and tasks, "
"briefly reference one or two noteworthy news themes, and suggest a daily focus. "
"Be actionable: for overdue or due-today tasks, suggest a concrete next step. "
"Aim for 4 to 8 sentences.\n\n"
)
elif slot in ("morning", "midday"):
base += (
"This is a MIDDAY CHECK-IN — brief progress nudge, not a full briefing. "
"Focus on what's been done and what hasn't been touched yet today. "
"If weather conditions have changed, mention it briefly. "
"Keep it to 2-3 sentences. Don't repeat news or the full task list.\n\n"
)
elif slot == "afternoon":
base += (
"This is the AFTERNOON WRAP-UP — help the user close out the day. "
"Summarize what was accomplished today. Flag anything still overdue. "
"Ask if there's anything to capture before tomorrow — notes, thoughts, follow-ups. "
"Keep it to 2-4 sentences. Reflective and encouraging tone.\n\n"
)
elif slot == "weekly_review":
base += (
"This is the WEEKLY REVIEW — a reflective recap of the past 7 days. "
"Summarize accomplishments, flag lingering overdue items, note how many notes were created, "
"and preview the upcoming week's key events. "
"Be reflective and encouraging — celebrate progress, gently flag what's stuck. "
"Aim for 5 to 8 sentences. This wraps up a chapter.\n\n"
)
if profile_body:
base += f"User profile:\n{profile_body}\n"
return base
def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str: def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str:
lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""] lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""]
# Weekly review — separate data path
if slot == "weekly_review":
weekly = internal_data.get("weekly_review") or {}
lines.append(f"COMPLETED THIS WEEK: {weekly.get('completed_count', 0)} tasks")
if weekly.get("completed_highlights"):
lines.extend(f" - {t}" for t in weekly["completed_highlights"])
lines.append("")
if weekly.get("overdue_count", 0) > 0:
lines.append(f"STILL OVERDUE: {weekly['overdue_count']} task(s)")
lines.extend(f" - {t}" for t in weekly.get("overdue_items", []))
lines.append("")
lines.append(f"NOTES CREATED: {weekly.get('notes_created', 0)}")
lines.append("")
if weekly.get("active_projects"):
lines.append(f"ACTIVE PROJECTS: {', '.join(weekly['active_projects'])}")
lines.append("")
if weekly.get("upcoming_events"):
lines.append("UPCOMING WEEK:")
lines.extend(f" - {e}" for e in weekly["upcoming_events"])
lines.append("")
return "\n".join(lines)
# For check-in slots, build a slimmer prompt
if slot in ("morning", "midday"):
# Just tasks status + any weather changes
if internal_data.get("due_today"):
lines.append("TASKS DUE TODAY (note which are still untouched):")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if internal_data.get("overdue_tasks"):
lines.append(f"STILL OVERDUE: {len(internal_data['overdue_tasks'])} task(s)")
lines.append("")
if internal_data.get("calendar_events"):
lines.append("REMAINING EVENTS TODAY:")
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("")
return "\n".join(lines)
if slot == "afternoon":
if internal_data.get("due_today"):
lines.append("TASKS THAT WERE DUE TODAY:")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} remaining):")
lines.extend(f" - {t}" for t in overdue[:3])
lines.append("")
lines.append("Ask the user if there's anything to capture before tomorrow — a note, a thought, a follow-up task.")
return "\n".join(lines)
# ── Full compilation prompt below ──────────────────────────────────────────
# Weather (brief — card handles detail) # Weather (brief — card handles detail)
weather = external_data.get("weather") or [] weather = external_data.get("weather") or []
if weather: if weather:
@@ -298,32 +602,104 @@ def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, te
lines.extend(f" - {e}" for e in internal_data["calendar_events"]) lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("") lines.append("")
# Weather warnings for outdoor events
if internal_data.get("weather_warnings"):
lines.append("WEATHER ALERTS (mention these naturally when discussing affected events):")
lines.extend(f"{w}" for w in internal_data["weather_warnings"])
lines.append("")
# Tasks due today # Tasks due today
if internal_data.get("due_today"): if internal_data.get("due_today"):
lines.append("DUE TODAY:") lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):")
lines.extend(f" - {t}" for t in internal_data["due_today"]) lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("") lines.append("")
# Overdue tasks (brief mention only) # Overdue tasks
if internal_data.get("overdue_tasks"): if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"] overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):") lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}) — suggest rescheduling or breaking down:")
lines.extend(f" - {t}" for t in overdue[:3]) lines.extend(f" - {t}" for t in overdue[:5])
if len(overdue) > 3: if len(overdue) > 5:
lines.append(f" (and {len(overdue) - 3} more)") lines.append(f" (and {len(overdue) - 5} more)")
lines.append("") lines.append("")
# News highlights (top 3 with excerpts — right panel shows full list) # High priority in-progress
if internal_data.get("high_priority"):
lines.append("HIGH PRIORITY:")
lines.extend(f" - {t}" for t in internal_data["high_priority"])
lines.append("")
# Your Day — prioritized focus + calendar gaps (compilation slot only)
if internal_data.get("focus_tasks") and slot == "compilation":
lines.append("YOUR DAY — top tasks to focus on (mention these as a suggested plan):")
for i, t in enumerate(internal_data["focus_tasks"], 1):
lines.append(f" {i}. {t}")
if internal_data.get("event_time_blocks"):
lines.append("")
lines.append(" Scheduled time blocks:")
lines.extend(f" {b}" for b in internal_data["event_time_blocks"])
lines.append(" (Identify free blocks between events for focused work.)")
lines.append("")
# Project continuity — what was the user working on?
if internal_data.get("recent_activity") and slot == "compilation":
lines.append("YESTERDAY'S ACTIVITY (mention what the user was working on to help them pick up):")
lines.extend(f" - {a}" for a in internal_data["recent_activity"])
lines.append("")
if internal_data.get("stale_projects") and slot == "compilation":
lines.append("STALE PROJECTS (no activity in 7+ days — gently flag):")
lines.extend(f" - {p}" for p in internal_data["stale_projects"])
lines.append("")
# News — clustered by topic, ranked by preference score
rss = external_data.get("rss_items") or [] rss = external_data.get("rss_items") or []
topic_scores = external_data.get("topic_scores") or {}
if rss: if rss:
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):") # Group articles by their primary topic
for item in rss[:3]: clusters: dict[str, list[dict]] = {}
source = item.get("feed_title") or item.get("source") or "News" uncategorized: list[dict] = []
title = item.get("title", "") for item in rss:
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip() topics = item.get("topics") or []
lines.append(f" [{source}] {title}") if topics:
if excerpt: primary = topics[0]
lines.append(f" {excerpt}") clusters.setdefault(primary, []).append(item)
else:
uncategorized.append(item)
# Score each cluster by aggregate preference (positive = user likes this topic)
def cluster_score(topic: str, items: list[dict]) -> float:
pref = topic_scores.get(topic, 0.0)
return pref * 2.0 + len(items) # preference-weighted, size as tiebreak
# Filter out clusters where the user has a strongly negative preference
filtered_clusters = [
(topic, items) for topic, items in clusters.items()
if topic_scores.get(topic, 0.0) >= -2.0
]
# Sort by preference-weighted score
sorted_clusters = sorted(filtered_clusters, key=lambda x: cluster_score(x[0], x[1]), reverse=True)
lines.append("NEWS THEMES (synthesize 1-2 themes into your briefing; mention article count per theme):")
for i, (topic, items) in enumerate(sorted_clusters[:4]):
count = len(items)
titles = [it.get("title", "") for it in items[:3]]
sources = list({it.get("feed_title") or it.get("source") or "News" for it in items})
pref_indicator = ""
pref = topic_scores.get(topic, 0.0)
if pref >= 2.0:
pref_indicator = "" # user's preferred topic
lines.append(f" [{topic.title()}]{pref_indicator} ({count} article{'s' if count != 1 else ''} from {', '.join(sources[:2])})")
for t in titles:
lines.append(f"{t}")
# Include excerpt for preferred clusters or the top cluster
if pref >= 1.0 or i == 0:
excerpt = (items[0].get("content") or items[0].get("snippet") or "")[:400].strip()
if excerpt:
lines.append(f" > {excerpt}")
if uncategorized:
lines.append(f" [Other] ({len(uncategorized)} uncategorized article{'s' if len(uncategorized) != 1 else ''})")
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
@@ -389,6 +765,10 @@ async def run_compilation(
get_cached_weather_rows(user_id), get_cached_weather_rows(user_id),
) )
# Weekly review: gather 7-day summary data
if slot == "weekly_review":
internal_data["weekly_review"] = await _gather_weekly_review(user_id)
# Task change detection # Task change detection
all_tasks = internal_data.get("all_tasks_raw", []) all_tasks = internal_data.get("all_tasks_raw", [])
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks) changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
@@ -429,10 +809,11 @@ async def run_compilation(
external_data_filtered = { external_data_filtered = {
"rss_items": filtered_rss, "rss_items": filtered_rss,
"weather": [], "weather": [],
"topic_scores": topic_scores,
} }
briefing_text = await _llm_synthesise( briefing_text = await _llm_synthesise(
_unified_system_prompt(profile_context), _unified_system_prompt(profile_context, slot),
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit), _unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
model, model,
num_ctx=8192, num_ctx=8192,
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
import asyncio import asyncio
import logging import logging
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -36,6 +36,11 @@ SLOTS = [
("afternoon", 16, 0), ("afternoon", 16, 0),
] ]
# Weekly review runs Sunday at 6pm by default
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
WEEKLY_REVIEW_HOUR = 18
WEEKLY_REVIEW_MINUTE = 0
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
) )
# Weekly review job — runs once per week
weekly_jid = _job_id(user_id, "weekly_review")
weekly_on = enabled_slots.get("weekly_review", True)
if weekly_on:
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(
day_of_week=WEEKLY_REVIEW_DAY,
hour=WEEKLY_REVIEW_HOUR,
minute=WEEKLY_REVIEW_MINUTE,
timezone=tz,
),
args=[user_id, "weekly_review"],
id=weekly_jid,
replace_existing=True,
misfire_grace_time=7200,
)
elif _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz) logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
@@ -113,6 +138,9 @@ def _remove_user_jobs(user_id: int) -> None:
jid = _job_id(user_id, slot_name) jid = _job_id(user_id, slot_name)
if _scheduler.get_job(jid): if _scheduler.get_job(jid):
_scheduler.remove_job(jid) _scheduler.remove_job(jid)
weekly_jid = _job_id(user_id, "weekly_review")
if _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Removed briefing jobs for user %d", user_id) logger.info("Removed briefing jobs for user %d", user_id)
@@ -134,6 +162,35 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
# ── Job execution ───────────────────────────────────────────────────────────── # ── Job execution ─────────────────────────────────────────────────────────────
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.project import Project
from fabledassistant.models.note import Note
from fabledassistant.models import async_session as _session
paused: list[str] = []
threshold = datetime.now(timezone.utc) - timedelta(days=14)
try:
async with _session() as session:
projects = (await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)).scalars().all()
for p in projects:
latest = (await session.execute(
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
)).scalar()
if latest is None or latest < threshold:
p.status = "paused"
paused.append(p.title or "Untitled")
if paused:
await session.commit()
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
except Exception:
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
return paused
async def _run_slot_for_user(user_id: int, slot: str) -> None: async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user.""" """Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import ( from fabledassistant.services.briefing_conversations import (
@@ -164,6 +221,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation": if slot == "compilation":
# Auto-pause stale projects before compiling the briefing
await _auto_pause_stale_projects(user_id)
# Refresh external data first # Refresh external data first
try: try:
import json import json
+19 -13
View File
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str: def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template.""" """Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html> <html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head> <head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> <meta charset="utf-8">
<div style="padding:32px 16px;"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
</head>
<body style="margin:0;padding:0;background:#f5f3ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="padding:36px 16px 48px;">
<div style="max-width:520px;margin:0 auto;"> <div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Card -->
<div style="background:#ffffff;border:1px solid #ddd6fe;border-radius:14px;overflow:hidden;box-shadow:0 4px 24px rgba(109,40,217,0.08);">
<!-- Header --> <!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;"> <div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:6px;"> <div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span> {_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
</div> </div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p> <p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div> </div>
<!-- Body --> <!-- Body -->
<div style="padding:28px 24px;"> <div style="padding:32px 28px;color:#1e1b4b;">
{body} {body}
</div> </div>
<!-- Footer --> <!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;"> <div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p> <p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
</div> </div>
</div> </div>
@@ -169,7 +175,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
async def send_test_email(to: str) -> None: async def send_test_email(to: str) -> None:
"""Send a branded test email.""" """Send a branded test email."""
body = """ body = """
<p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p> <p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p> <p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
""" """
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body)) await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
+107 -40
View File
@@ -29,10 +29,15 @@ def _note_to_item(note: Note) -> dict:
item["relationship"] = meta.get("relationship", "") item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "") item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "") item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
elif note.entity_type == "place": elif note.entity_type == "place":
item["address"] = meta.get("address", "") item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "") item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "") item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
elif note.entity_type == "list": elif note.entity_type == "list":
# Parse markdown task list syntax into structured items # Parse markdown task list syntax into structured items
body = note.body or "" body = note.body or ""
@@ -46,6 +51,14 @@ def _note_to_item(note: Note) -> dict:
item["item_count"] = len(list_items) item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"]) item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body item["body"] = body
# Task fields — override note_type and add status/priority/due_date
if note.is_task:
item["note_type"] = "task"
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
return item return item
@@ -69,17 +82,15 @@ async def query_knowledge(
) )
async with async_session() as session: async with async_session() as session:
base = ( base = select(Note).where(Note.user_id == user_id)
select(Note)
.where(Note.user_id == user_id)
.where(Note.status.is_(None)) # exclude tasks
)
if note_type: if note_type == "task":
base = base.where(Note.note_type == note_type) base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else: else:
# Exclude tasks — already done above; also exclude any legacy nulls # All types including tasks
base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) pass
for tag in tags: for tag in tags:
base = base.where(Note.tags.contains([tag])) base = base.where(Note.tags.contains([tag]))
@@ -111,31 +122,74 @@ async def _semantic_knowledge_search(
limit: int, limit: int,
offset: int, offset: int,
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
"""Semantic search over knowledge objects, with SQL filters applied post-rank.""" """Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
async with async_session() as session:
pattern = f"%{q}%"
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
base = base.order_by(
Note.title.ilike(pattern).desc(),
Note.updated_at.desc(),
).limit(limit * 2)
keyword_notes = list((await session.execute(base)).scalars().all())
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity
semantic_notes: list[Note] = []
try: try:
from fabledassistant.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
# Fetch a larger candidate set to allow for filtering is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes( candidates = await semantic_search_notes(
user_id=user_id, user_id=user_id,
query=q, query=q,
limit=min(200, limit * 8), limit=min(200, limit * 4),
threshold=0.3, threshold=0.3,
is_task=False, is_task=is_task_filter,
) )
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
semantic_notes.append(note)
except Exception: except Exception:
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
results = [] # 3. Merge — keyword matches first, then semantic (deduplicated)
for _score, note in candidates: seen_ids: set[int] = set()
if note_type and note.entity_type != note_type: merged: list[Note] = []
continue for note in keyword_notes:
if tags and not all(t in (note.tags or []) for t in tags): if note.id not in seen_ids:
continue seen_ids.add(note.id)
results.append(note) merged.append(note)
for note in semantic_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
total = len(results) total = len(merged)
page_items = results[offset: offset + limit] page_items = merged[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total return [_note_to_item(n) for n in page_items], total
@@ -145,12 +199,13 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
base = ( base = (
select(func.unnest(Note.tags).label("tag")) select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id) .where(Note.user_id == user_id)
.where(Note.status.is_(None))
) )
if note_type: if note_type == "task":
base = base.where(Note.note_type == note_type) base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else: else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) pass
stmt = base.distinct().order_by("tag") stmt = base.distinct().order_by("tag")
rows = list((await session.execute(stmt)).scalars().all()) rows = list((await session.execute(stmt)).scalars().all())
return [r for r in rows if r] return [r for r in rows if r]
@@ -159,6 +214,7 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]: async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
"""Return per-type count of knowledge objects for the sidebar display.""" """Return per-type count of knowledge objects for the sidebar display."""
async with async_session() as session: async with async_session() as session:
# Count non-task types
stmt = ( stmt = (
select(Note.note_type, func.count(Note.id)) select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id) .where(Note.user_id == user_id)
@@ -170,11 +226,23 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
for tag in tags: for tag in tags:
stmt = stmt.where(Note.tags.contains([tag])) stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all()) rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows} counts = {row[0]: row[1] for row in rows}
# Ensure all types present even if zero
for t in ("note", "person", "place", "list"): # Count tasks separately (is_task = status IS NOT NULL)
task_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
)
if tags:
for tag in tags:
task_stmt = task_stmt.where(Note.tags.contains([tag]))
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
for t in ("note", "person", "place", "list", "task"):
counts.setdefault(t, 0) counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list")) counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
return counts return counts
@@ -197,15 +265,14 @@ async def query_knowledge_ids(
return [item["id"] for item in items], total return [item["id"] for item in items], total
async with async_session() as session: async with async_session() as session:
base = ( base = select(Note.id).where(Note.user_id == user_id)
select(Note.id)
.where(Note.user_id == user_id) if note_type == "task":
.where(Note.status.is_(None)) base = base.where(Note.status.isnot(None))
) elif note_type:
if note_type: base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
base = base.where(Note.note_type == note_type)
else: else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) pass
for tag in tags: for tag in tags:
base = base.where(Note.tags.contains([tag])) base = base.where(Note.tags.contains([tag]))
+18
View File
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out return out
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from fabledassistant.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None: async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old.""" """Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio import asyncio
@@ -92,6 +108,7 @@ async def create_note(
await session.refresh(note) await session.refresh(note)
if project_id is not None: if project_id is not None:
await _maybe_reactivate_project(project_id)
await _maybe_trigger_project_summary(user_id, project_id) await _maybe_trigger_project_summary(user_id, project_id)
return note return note
@@ -284,6 +301,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
await create_version(user_id, note_id, old_body, old_title, old_tags) await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None: if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
await _maybe_trigger_project_summary(user_id, note.project_id) await _maybe_trigger_project_summary(user_id, note.project_id)
return note return note
@@ -103,7 +103,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
We received a request to reset your password. Click the button below to choose a new password. We received a request to reset your password. Click the button below to choose a new password.
</p> </p>
<div style="text-align:center;margin:24px 0;"> <div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;"> <a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password Reset Password
</a> </a>
</div> </div>
@@ -136,7 +136,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
Click the button below to create your account. Click the button below to create your account.
</p> </p>
<div style="text-align:center;margin:24px 0;"> <div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;"> <a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation Accept Invitation
</a> </a>
</div> </div>
+74
View File
@@ -184,6 +184,80 @@ async def geocode(query: str) -> tuple[float, float, str]:
return float(r["lat"]), float(r["lon"]), r.get("display_name", query) return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
"""Fetch current temperature + conditions + next 3 hours precipitation.
Lightweight call — no daily forecast, no caching needed.
Returns dict with: temperature, windspeed, weathercode, description,
and precip_next_3h (list of hourly precip probabilities).
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"current_weather": "true",
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
current = data.get("current_weather", {})
hourly = data.get("hourly", {})
hourly_times = hourly.get("time", [])
hourly_precip = hourly.get("precipitation_probability", [])
# Find the current hour index and get next 3 hours of precip
current_time = current.get("time", "")
precip_next_3h = []
try:
idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
precip_next_3h = hourly_precip[idx:idx + 3]
except (StopIteration, IndexError):
pass
code = current.get("weathercode", 0)
return {
"temperature": current.get("temperature"),
"windspeed": current.get("windspeed"),
"weathercode": code,
"description": describe_weathercode(code),
"time": current_time,
"precip_next_3h": precip_next_3h,
}
except Exception:
logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
return None
async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
"""Fetch today's hourly precipitation probabilities.
Returns a dict of ISO hour string → probability percentage, e.g.:
{"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
hourly = data.get("hourly", {})
times = hourly.get("time", [])
probs = hourly.get("precipitation_probability", [])
return {t: p for t, p in zip(times, probs) if p is not None}
except Exception:
logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
return {}
async def _fetch_open_meteo(lat: float, lon: float) -> dict: async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data.""" """Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client: