Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fb81bc1ed | |||
| c1bc73da8e | |||
| 3b8d40fea3 | |||
| 2f5abc034e | |||
| 7a01733334 | |||
| 7d5611d00b | |||
| e5821ef3f8 | |||
| 43231f44d2 | |||
| ab0b9c3199 | |||
| d12fc5d282 | |||
| 4c3d67994b | |||
| 126bad7d99 | |||
| 28fae75258 | |||
| 058bd76719 | |||
| d56b57ee40 | |||
| 8762552234 | |||
| 6cbf9be052 | |||
| 3687fbeeb9 | |||
| a6543c1dc5 | |||
| 4558dd578a | |||
| 8647f52fbc | |||
| 304affb837 | |||
| 2e3f90384d | |||
| 16b9ed9392 | |||
| f9c6802939 | |||
| f2debd8a5b | |||
| f8f14eea0f | |||
| 64c19932a7 | |||
| 317d8f25d0 | |||
| 87f74b1cd5 | |||
| f7fda0adca | |||
| 0ca39a2e34 | |||
| 16d9af8b96 | |||
| 2ac894b5d1 | |||
| 7e81e50e3e | |||
| d8945536c4 | |||
| f30e90ef8d | |||
| 00f82f8cba | |||
| f953a36b86 | |||
| 4bc7f9eaac | |||
| 670de547ad | |||
| a684d84edf | |||
| 34729cf1cf | |||
| dd16b39218 | |||
| 0bd362f582 | |||
| 0afa2d8771 | |||
| 93af4e6bab | |||
| 6e3de91a6f | |||
| 6e0c528126 |
@@ -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. Mon–Fri 9am–5pm" @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. Mon–Fri 9–5" @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">×</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. Mon–Fri 9am–5pm" | `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
|
||||
@@ -90,7 +90,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case "h": router.push("/"); 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 "c": router.push("/chat"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
@@ -202,7 +202,7 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">t</kbd>
|
||||
<span class="shortcut-desc">Tasks</span>
|
||||
<span class="shortcut-desc">Knowledge (tasks)</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
--color-text-muted: #999999;
|
||||
--color-border: #dddde8;
|
||||
--color-input-border: #c8c8d8;
|
||||
--color-primary: #6366f1;
|
||||
--color-primary: #7c3aed;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede9fe;
|
||||
--color-tag-text: #4f46e5;
|
||||
--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: #6366f1;
|
||||
--color-status-in-progress-bg: #ede9fe;
|
||||
--color-status-in-progress: #7c3aed;
|
||||
--color-status-in-progress-bg: #ede5ff;
|
||||
--color-status-done: #34a853;
|
||||
--color-status-done-bg: #e6f4ea;
|
||||
--color-priority-low: #5f9ea0;
|
||||
@@ -44,38 +44,50 @@
|
||||
--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(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-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--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 */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--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"] {
|
||||
--color-bg: #111113;
|
||||
--color-bg-secondary: #18181f;
|
||||
--color-bg-card: #1e1e27;
|
||||
--color-surface: #1a1b22;
|
||||
--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(99, 102, 241, 0.10);
|
||||
--color-input-border: rgba(99, 102, 241, 0.22);
|
||||
--color-primary: #818cf8;
|
||||
--color-border: rgba(124, 58, 237, 0.22);
|
||||
--color-input-border: rgba(124, 58, 237, 0.35);
|
||||
--color-primary: #a78bfa;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #a5b4fc;
|
||||
--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: #818cf8;
|
||||
--color-status-in-progress: #a78bfa;
|
||||
--color-status-in-progress-bg: #2a2a45;
|
||||
--color-status-done: #4caf50;
|
||||
--color-status-done-bg: #1b3a20;
|
||||
@@ -88,19 +100,30 @@
|
||||
--color-wikilink: #c4b5fd;
|
||||
--color-wikilink-bg: #2a1a45;
|
||||
--color-overdue: #f44336;
|
||||
--color-code-bg: #16161d;
|
||||
--color-code-inline-bg: #1e1e2d;
|
||||
--color-table-stripe: #16161e;
|
||||
--color-code-bg: #12121a;
|
||||
--color-code-inline-bg: #1a1a2a;
|
||||
--color-table-stripe: #14141e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1e1e27;
|
||||
--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(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;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-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) */
|
||||
|
||||
@@ -68,18 +68,19 @@ router.afterEach(() => {
|
||||
<!-- Left: brand -->
|
||||
<router-link to="/" class="nav-brand">
|
||||
<AppLogo :size="34" />
|
||||
Fabled Assistant
|
||||
<span class="brand-text">Fabled</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<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="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<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>
|
||||
|
||||
<!-- 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="/briefing" class="nav-link">Briefing</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="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
@@ -152,40 +152,51 @@ router.afterEach(() => {
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left */
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-primary);
|
||||
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 — absolutely positioned so it's truly centered regardless of side widths */
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
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 */
|
||||
@@ -199,20 +210,20 @@ router.afterEach(() => {
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
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-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-text);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary);
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
border-radius: 0;
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -234,7 +245,7 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
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-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
|
||||
@@ -243,6 +254,10 @@ router.afterEach(() => {
|
||||
0%, 100% { opacity: 1; }
|
||||
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) */
|
||||
.btn-icon {
|
||||
@@ -369,6 +384,11 @@ router.afterEach(() => {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
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 {
|
||||
min-height: 36px;
|
||||
|
||||
@@ -11,6 +11,12 @@ defineProps<{ size?: number }>();
|
||||
:height="size ?? 24"
|
||||
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 -->
|
||||
<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 -->
|
||||
@@ -37,7 +43,7 @@ defineProps<{ size?: number }>();
|
||||
|
||||
<style scoped>
|
||||
.logo-book {
|
||||
fill: var(--color-primary);
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
.logo-spine {
|
||||
|
||||
@@ -247,7 +247,7 @@ async function finish() {
|
||||
}
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.wizard-step-label {
|
||||
@@ -375,7 +375,7 @@ async function finish() {
|
||||
}
|
||||
.btn-wizard-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -391,7 +391,7 @@ defineExpose({ focus, prefill })
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
@@ -400,7 +400,7 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
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-abort-inline {
|
||||
|
||||
@@ -338,7 +338,7 @@ defineExpose({ focus, prefill, send })
|
||||
<p
|
||||
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
>Send a message to start the conversation.</p>
|
||||
>Start a conversation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,6 +525,8 @@ defineExpose({ focus, prefill, send })
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -746,6 +748,9 @@ defineExpose({ focus, prefill, send })
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
}
|
||||
|
||||
/* ── Widget variant ── */
|
||||
|
||||
@@ -64,36 +64,114 @@ function toIso(date: string, time: string): string {
|
||||
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() {
|
||||
if (props.event) {
|
||||
title.value = props.event.title;
|
||||
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);
|
||||
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)) : "";
|
||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(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) : addHours(startTime.value || "09:00", 1);
|
||||
description.value = props.event.description || "";
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
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 {
|
||||
title.value = "";
|
||||
allDay.value = false;
|
||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||
const roundedStart = nextRoundedTime();
|
||||
startDate.value = base;
|
||||
startTime.value = "09:00";
|
||||
startTime.value = roundedStart;
|
||||
endDate.value = base;
|
||||
endTime.value = "10:00";
|
||||
endTime.value = addHours(roundedStart, 1);
|
||||
description.value = "";
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
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.initialDate, resetForm);
|
||||
|
||||
@@ -113,6 +191,10 @@ async function save() {
|
||||
toast.show("Start date is required", "error");
|
||||
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 end_dt = endDate.value
|
||||
@@ -199,18 +281,19 @@ async function doDelete() {
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start</label>
|
||||
<label class="so-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="so-input dt-date" />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
|
||||
<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" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +315,7 @@ async function doDelete() {
|
||||
<label class="so-label so-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,13 +440,19 @@ async function doDelete() {
|
||||
box-sizing: border-box;
|
||||
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; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.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 {
|
||||
background: var(--color-input-bg, #111113);
|
||||
@@ -376,8 +465,8 @@ async function doDelete() {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -403,7 +492,7 @@ async function doDelete() {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -94,10 +94,10 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
/* ── Streaming ── */
|
||||
.iap-streaming {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.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 {
|
||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -172,8 +172,8 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
font-family: inherit;
|
||||
}
|
||||
.iap-btn-toggle:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.iap-actions {
|
||||
|
||||
@@ -78,6 +78,7 @@ const groups = [
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
<span class="btn-icon" v-html="ICONS[btn.id]" />
|
||||
|
||||
@@ -64,11 +64,11 @@ function goEdit() {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function goEdit() {
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
|
||||
@@ -337,7 +337,7 @@ onMounted(async () => {
|
||||
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -40,7 +40,7 @@ defineEmits<{
|
||||
}
|
||||
.tag-pill:hover {
|
||||
color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.dismiss {
|
||||
background: none;
|
||||
|
||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -751,7 +751,7 @@ function closeEventSlideOver(changed = false) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -773,14 +773,14 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
.tool-event-card.clickable { cursor: pointer; }
|
||||
.tool-event-card.clickable:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card, #16161a));
|
||||
}
|
||||
.tool-event-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
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 analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
|
||||
@@ -45,8 +45,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/notes",
|
||||
name: "notes",
|
||||
component: () => import("@/views/NotesListView.vue"),
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/notes/new",
|
||||
@@ -85,8 +84,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
component: () => import("@/views/TasksListView.vue"),
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/tasks/new",
|
||||
|
||||
@@ -60,6 +60,27 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
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() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||
@@ -90,6 +111,7 @@ async function loadAll() {
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
loadCurrentConditions(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
@@ -198,11 +220,18 @@ useBackgroundRefresh(
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => { _mounted = false })
|
||||
onUnmounted(() => {
|
||||
_mounted = false
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
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>
|
||||
|
||||
@@ -235,6 +264,7 @@ onMounted(async () => {
|
||||
<!-- Left column: Weather -->
|
||||
<div class="briefing-left">
|
||||
<div class="panel-label">Weather</div>
|
||||
<!-- Forecast card -->
|
||||
<template v-if="weatherData.length">
|
||||
<WeatherCard
|
||||
v-for="loc in weatherData"
|
||||
@@ -457,6 +487,8 @@ onMounted(async () => {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Current conditions (live) ─────────────────────────── */
|
||||
|
||||
.panel-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -435,7 +435,7 @@ const upcomingGrouped = computed(() => {
|
||||
}
|
||||
|
||||
.btn-new-event {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -482,8 +482,8 @@ const upcomingGrouped = computed(() => {
|
||||
}
|
||||
:deep(.fc-button:hover),
|
||||
:deep(.fc-button-active) {
|
||||
background: var(--color-primary, #6366f1) !important;
|
||||
border-color: var(--color-primary, #6366f1) !important;
|
||||
background: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||
@@ -494,7 +494,7 @@ const upcomingGrouped = computed(() => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
:deep(.fc-daygrid-day.fc-day-today) {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
:deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
@@ -505,7 +505,7 @@ const upcomingGrouped = computed(() => {
|
||||
}
|
||||
:deep(.fc-event-main) { color: #fff; }
|
||||
: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-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||
@@ -574,8 +574,8 @@ const upcomingGrouped = computed(() => {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.picker-month.active {
|
||||
background: rgba(99,102,241,0.22);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: rgba(124,58,237,0.22);
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -634,7 +634,7 @@ const upcomingGrouped = computed(() => {
|
||||
.upcoming-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ev-color, #6366f1);
|
||||
background: var(--ev-color, #7c3aed);
|
||||
}
|
||||
|
||||
.upcoming-card-body {
|
||||
@@ -691,7 +691,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.popover-accent {
|
||||
height: 4px;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
@@ -750,7 +750,7 @@ const upcomingGrouped = computed(() => {
|
||||
}
|
||||
.popover-btn:hover { opacity: 0.85; }
|
||||
.popover-btn--edit {
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.popover-btn--close {
|
||||
|
||||
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem 1.5rem;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -605,20 +605,20 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-workspace-hero:hover {
|
||||
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; }
|
||||
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
font-weight: 500;
|
||||
}
|
||||
.urgency-in-progress {
|
||||
background: color-mix(in srgb, #6366f1 15%, transparent);
|
||||
color: #6366f1;
|
||||
background: color-mix(in srgb, #7c3aed 15%, transparent);
|
||||
color: #7c3aed;
|
||||
}
|
||||
.urgency-todo {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
@@ -892,14 +892,14 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
width: 100%;
|
||||
}
|
||||
.upcoming-event-card:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card));
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
|
||||
}
|
||||
.upcoming-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const chatStore = useChatStore();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "person" | "place" | "list";
|
||||
note_type: "note" | "person" | "place" | "list" | "task";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
@@ -27,12 +27,20 @@ interface KnowledgeItem {
|
||||
relationship?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
birthday?: string;
|
||||
organization?: string;
|
||||
address?: string;
|
||||
hours?: string;
|
||||
website?: string;
|
||||
category?: string;
|
||||
item_count?: number;
|
||||
checked_count?: number;
|
||||
list_items?: { text: string; checked: boolean }[];
|
||||
body?: string;
|
||||
// Task-specific
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
interface UpcomingEvent {
|
||||
@@ -44,7 +52,7 @@ interface UpcomingEvent {
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -69,7 +77,18 @@ const newNoteMenuOpen = ref(false);
|
||||
|
||||
function createNew(type: string) {
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -307,8 +326,17 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
|
||||
|
||||
// ─── 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) {
|
||||
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 {
|
||||
@@ -344,6 +372,7 @@ function setupObserver() {
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', onClickOutsideNewNote);
|
||||
await reset();
|
||||
fetchTags();
|
||||
fetchCounts();
|
||||
@@ -353,6 +382,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutsideNewNote);
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
observer?.disconnect();
|
||||
});
|
||||
@@ -392,13 +422,30 @@ onUnmounted(() => {
|
||||
<aside class="filter-panel">
|
||||
<!-- New note button -->
|
||||
<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>
|
||||
<button class="btn-new-note" @click="newNoteMenuOpen = !newNoteMenuOpen">
|
||||
<span class="btn-new-icon">+</span> New
|
||||
</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>
|
||||
<button @click="createNew('note')">
|
||||
<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>
|
||||
Note
|
||||
</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>
|
||||
|
||||
@@ -413,11 +460,11 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</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"
|
||||
class="filter-btn"
|
||||
: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 v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||
@@ -474,9 +521,8 @@ onUnmounted(() => {
|
||||
<!-- Loading / empty -->
|
||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Card grid -->
|
||||
@@ -502,11 +548,13 @@ onUnmounted(() => {
|
||||
<!-- Person specifics -->
|
||||
<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>
|
||||
|
||||
<!-- Place specifics -->
|
||||
<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>
|
||||
@@ -533,6 +581,26 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</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 -->
|
||||
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
||||
</div>
|
||||
@@ -652,17 +720,17 @@ onUnmounted(() => {
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
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 {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
background: #7c3aed;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
||||
@@ -677,7 +745,7 @@ onUnmounted(() => {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.today-link {
|
||||
color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
@@ -703,73 +771,89 @@ onUnmounted(() => {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.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 {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
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;
|
||||
}
|
||||
/* ── New note button ─────────────────────────────────────── */
|
||||
/* ── New item button ─────────────────────────────────────── */
|
||||
.new-note-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn-new-note {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||
border-right: none;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary, #818cf8);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
font-weight: 600;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
||||
.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;
|
||||
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
|
||||
.btn-new-icon {
|
||||
font-size: 1.1rem;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-tertiary, #1a1b1e);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
padding: 9px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -790,8 +874,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
||||
.filter-btn.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: var(--color-primary-wash);
|
||||
color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
.filter-btn-label { flex: 1; }
|
||||
@@ -807,8 +891,8 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
|
||||
@@ -853,7 +937,7 @@ onUnmounted(() => {
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--color-primary, #6366f1); }
|
||||
.search-input:focus { border-color: var(--color-primary); }
|
||||
.sort-select {
|
||||
padding: 7px 10px;
|
||||
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.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
@@ -890,6 +974,8 @@ onUnmounted(() => {
|
||||
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);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
@@ -911,16 +997,56 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
.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);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(124, 58, 237, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(124, 58, 237, 0.20); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
||||
.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 {
|
||||
@@ -934,10 +1060,11 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
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--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||
.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-title {
|
||||
@@ -1038,7 +1165,48 @@ onUnmounted(() => {
|
||||
background: rgba(255,255,255,0.05);
|
||||
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 ─────────────────────────────────────── */
|
||||
.knowledge-empty {
|
||||
@@ -1053,6 +1221,13 @@ onUnmounted(() => {
|
||||
gap: 6px;
|
||||
}
|
||||
.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 ────────────────────────────────────────────── */
|
||||
.scroll-sentinel {
|
||||
|
||||
@@ -38,6 +38,81 @@ const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
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 titleRef = ref<HTMLInputElement | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
@@ -49,6 +124,15 @@ const noteId = computed(() =>
|
||||
);
|
||||
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));
|
||||
|
||||
// AI Assist — pass noteId for draft persistence
|
||||
@@ -219,6 +303,13 @@ onMounted(async () => {
|
||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||
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;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
@@ -233,6 +324,9 @@ onMounted(async () => {
|
||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||||
noteType.value = qt as NoteType;
|
||||
}
|
||||
if (noteType.value === 'list') {
|
||||
listItems.value = [{ text: '', checked: false }];
|
||||
}
|
||||
}
|
||||
|
||||
// Restore pending draft if any
|
||||
@@ -243,6 +337,9 @@ onMounted(async () => {
|
||||
// No draft — normal
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
titleRef.value?.focus();
|
||||
|
||||
// Initial link suggestions
|
||||
fetchLinkSuggestions();
|
||||
});
|
||||
@@ -250,11 +347,12 @@ onMounted(async () => {
|
||||
async function save() {
|
||||
if (saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
body: finalBody,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
@@ -273,7 +371,7 @@ async function save() {
|
||||
} else {
|
||||
const note = await store.createNote({
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
body: finalBody,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
@@ -304,7 +402,7 @@ async function confirmDelete() {
|
||||
await store.deleteNote(noteId.value);
|
||||
dirty.value = false;
|
||||
toast.show("Note deleted");
|
||||
router.push("/notes");
|
||||
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
||||
} catch {
|
||||
toast.show("Failed to delete note", "error");
|
||||
}
|
||||
@@ -314,9 +412,10 @@ async function confirmDelete() {
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
try {
|
||||
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,
|
||||
note_type: noteType.value, metadata: { ...entityMeta },
|
||||
});
|
||||
@@ -346,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
<main class="editor-page note-editor-page">
|
||||
<div class="editor-header">
|
||||
<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">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
@@ -357,7 +456,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
ref="titleRef"
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
:placeholder="titlePlaceholder"
|
||||
class="title-input"
|
||||
@input="markDirty"
|
||||
@keydown.ctrl.s.prevent="save"
|
||||
@@ -370,48 +469,181 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
<!-- ── Main column ────────────────────────────────────────── -->
|
||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||
<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>
|
||||
<!-- ── 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>
|
||||
<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">
|
||||
<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="Write your note in Markdown..."
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error from assist -->
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
<!-- ── 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. Mon–Fri 9am–5pm" @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">×</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>
|
||||
|
||||
<!-- ── Sidebar ──────────────────────────────────────────── -->
|
||||
@@ -476,38 +708,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
</select>
|
||||
</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. Mon–Fri 9–5" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Link Suggestions -->
|
||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||
<div class="sb-label-row">
|
||||
@@ -731,7 +931,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sb-select:focus, .sb-input:focus {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
@@ -819,6 +1019,138 @@ onUnmounted(() => assist.clearSelection());
|
||||
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 */
|
||||
@media (max-width: 720px) {
|
||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -350,17 +350,17 @@ async function convertToTask() {
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
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;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
box-shadow: var(--glow-cta-hover);
|
||||
opacity: 0.95;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -20,7 +20,7 @@ interface Project {
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
permission?: string;
|
||||
@@ -40,7 +40,7 @@ const toast = useToastStore();
|
||||
const projects = ref<Project[]>([]);
|
||||
const loading = ref(false);
|
||||
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
|
||||
const showNewProjectModal = ref(false);
|
||||
@@ -103,6 +103,7 @@ async function createProject() {
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "paused") return "Paused";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
<!-- Filter tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
|
||||
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
|
||||
:key="tab"
|
||||
:class="['tab-btn', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
|
||||
</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 -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Project {
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
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() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -130,6 +138,7 @@ async function loadProject() {
|
||||
editStatus.value = data.status;
|
||||
editDirty.value = false;
|
||||
milestones.value = data.summary?.milestone_summary ?? [];
|
||||
autoCollapseCompleted(milestones.value);
|
||||
} catch {
|
||||
error.value = "Failed to load project.";
|
||||
} finally {
|
||||
@@ -143,6 +152,7 @@ async function loadMilestones() {
|
||||
`/api/projects/${projectId.value}/milestones`
|
||||
);
|
||||
milestones.value = data.milestones;
|
||||
autoCollapseCompleted(milestones.value);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
@@ -395,6 +405,7 @@ async function confirmDelete() {
|
||||
<label class="edit-label">Status</label>
|
||||
<select v-model="editStatus" class="edit-select">
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
@@ -676,17 +687,17 @@ async function confirmDelete() {
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
.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 {
|
||||
padding: 0.4rem 0.8rem;
|
||||
@@ -752,6 +763,7 @@ async function confirmDelete() {
|
||||
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-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-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 {
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -866,10 +878,10 @@ async function confirmDelete() {
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
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;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* ── Content area ────────────────────────────────────────────── */
|
||||
|
||||
@@ -3024,7 +3024,7 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
}
|
||||
.sidebar-item.active {
|
||||
color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
border-left-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -3489,7 +3489,7 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
}
|
||||
.sidebar-item.active {
|
||||
border-bottom-color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -362,7 +362,7 @@ async function confirmDelete() {
|
||||
await store.deleteTask(taskId.value);
|
||||
dirty.value = false;
|
||||
toast.show("Task deleted");
|
||||
router.push("/tasks");
|
||||
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
||||
} catch {
|
||||
toast.show("Failed to delete task", "error");
|
||||
}
|
||||
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
|
||||
<main class="editor-page task-editor-page">
|
||||
<div class="editor-header">
|
||||
<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">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
|
||||
@@ -480,23 +480,23 @@ const subTaskProgress = computed(() => {
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
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;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
box-shadow: var(--glow-cta-hover);
|
||||
opacity: 0.95;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-advance {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -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>
|
||||
@@ -155,6 +155,44 @@ async def get_weather():
|
||||
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"])
|
||||
@_REQUIRE
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ async def create_project_route():
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
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
|
||||
project = await create_project(
|
||||
uid,
|
||||
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "goal", "status", "color"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
if "status" in fields and fields["status"] not in ("active", "completed", "archived"):
|
||||
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", "paused", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||
project = await update_project(uid, project_id, **fields)
|
||||
if project is None:
|
||||
|
||||
@@ -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")]
|
||||
if task.get("status"):
|
||||
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
|
||||
if task.get("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", ""):
|
||||
parts.append(f"priority: {task['priority']}")
|
||||
if task.get("project_title"):
|
||||
parts.append(f"project: {task['project_title']}")
|
||||
return " — ".join(parts)
|
||||
|
||||
|
||||
@@ -134,6 +145,13 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
all_tasks: list[dict] = []
|
||||
try:
|
||||
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 = [
|
||||
{
|
||||
"task_id": t.id,
|
||||
@@ -141,21 +159,22 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
"status": t.status,
|
||||
"due_date": t.due_date.isoformat() if t.due_date else None,
|
||||
"priority": t.priority,
|
||||
"project_title": project_titles.get(t.project_id) if t.project_id else None,
|
||||
}
|
||||
for t in all_task_objs
|
||||
]
|
||||
overdue = [
|
||||
format_task(t) for t in all_tasks
|
||||
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
|
||||
format_task(t, today) for t in all_tasks
|
||||
if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled")
|
||||
]
|
||||
due_today = [
|
||||
format_task(t) for t in all_tasks
|
||||
if t.get("due_date") == today and t.get("status") != "done"
|
||||
format_task(t, today) for t in all_tasks
|
||||
if t.get("due_date") == today and t.get("status") not in ("done", "cancelled")
|
||||
]
|
||||
high_priority = [
|
||||
format_task(t) for t in all_tasks
|
||||
if t.get("priority") == "high" and t.get("status") not in ("done",) and
|
||||
format_task(t) not in overdue and format_task(t) not in due_today
|
||||
format_task(t, today) for t in all_tasks
|
||||
if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and
|
||||
format_task(t, today) not in overdue and format_task(t, today) not in due_today
|
||||
][:5]
|
||||
except Exception:
|
||||
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: list[str] = []
|
||||
internal_events: list[dict] = []
|
||||
try:
|
||||
from fabledassistant.services.events import list_events as list_internal_events
|
||||
today_date = datetime.now(user_tz).date()
|
||||
@@ -197,6 +217,87 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
except Exception:
|
||||
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_summary = []
|
||||
try:
|
||||
@@ -206,6 +307,51 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
except Exception:
|
||||
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 {
|
||||
"date": today,
|
||||
"overdue_tasks": overdue,
|
||||
@@ -214,6 +360,80 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
"calendar_events": calendar_events,
|
||||
"active_projects": projects_summary,
|
||||
"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 ""
|
||||
|
||||
|
||||
def _unified_system_prompt(profile_body: str) -> str:
|
||||
return (
|
||||
"You are a personal assistant delivering a daily briefing. "
|
||||
def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str:
|
||||
base = (
|
||||
"You are a personal assistant. "
|
||||
"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. "
|
||||
"Weave together what matters today: mention the weather in a sentence, note any calendar "
|
||||
"events or tasks due today, and briefly reference one or two noteworthy news stories. "
|
||||
"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 "")
|
||||
"Be warm, concise, and human. "
|
||||
"The user can reply to act on your suggestions via tool calls.\n\n"
|
||||
)
|
||||
|
||||
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:
|
||||
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 = external_data.get("weather") or []
|
||||
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.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
|
||||
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.append("")
|
||||
|
||||
# Overdue tasks (brief mention only)
|
||||
# Overdue tasks
|
||||
if internal_data.get("overdue_tasks"):
|
||||
overdue = internal_data["overdue_tasks"]
|
||||
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):")
|
||||
lines.extend(f" - {t}" for t in overdue[:3])
|
||||
if len(overdue) > 3:
|
||||
lines.append(f" (and {len(overdue) - 3} more)")
|
||||
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[:5])
|
||||
if len(overdue) > 5:
|
||||
lines.append(f" (and {len(overdue) - 5} more)")
|
||||
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 []
|
||||
topic_scores = external_data.get("topic_scores") or {}
|
||||
if rss:
|
||||
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):")
|
||||
for item in rss[:3]:
|
||||
source = item.get("feed_title") or item.get("source") or "News"
|
||||
title = item.get("title", "")
|
||||
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip()
|
||||
lines.append(f" [{source}] {title}")
|
||||
if excerpt:
|
||||
lines.append(f" {excerpt}")
|
||||
# Group articles by their primary topic
|
||||
clusters: dict[str, list[dict]] = {}
|
||||
uncategorized: list[dict] = []
|
||||
for item in rss:
|
||||
topics = item.get("topics") or []
|
||||
if topics:
|
||||
primary = topics[0]
|
||||
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("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -389,6 +765,10 @@ async def run_compilation(
|
||||
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
|
||||
all_tasks = internal_data.get("all_tasks_raw", [])
|
||||
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
|
||||
@@ -429,10 +809,11 @@ async def run_compilation(
|
||||
external_data_filtered = {
|
||||
"rss_items": filtered_rss,
|
||||
"weather": [],
|
||||
"topic_scores": topic_scores,
|
||||
}
|
||||
|
||||
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),
|
||||
model,
|
||||
num_ctx=8192,
|
||||
|
||||
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -36,6 +36,11 @@ SLOTS = [
|
||||
("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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
replace_existing=True,
|
||||
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)
|
||||
|
||||
|
||||
@@ -113,6 +138,9 @@ def _remove_user_jobs(user_id: int) -> None:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
if _scheduler.get_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)
|
||||
|
||||
|
||||
@@ -134,6 +162,35 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
|
||||
|
||||
# ── 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:
|
||||
"""Execute one slot job for one user."""
|
||||
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)
|
||||
|
||||
if slot == "compilation":
|
||||
# Auto-pause stale projects before compiling the briefing
|
||||
await _auto_pause_stale_projects(user_id)
|
||||
|
||||
# Refresh external data first
|
||||
try:
|
||||
import json
|
||||
|
||||
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
|
||||
def _email_html(title: str, body: str) -> str:
|
||||
"""Wrap email body content in the standard Fabled Assistant template."""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<div style="padding:32px 16px;">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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="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 -->
|
||||
<div style="background:#6366f1;padding:20px 24px;text-align:center;">
|
||||
<div style="margin-bottom:6px;">
|
||||
{_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>
|
||||
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
|
||||
<div style="margin-bottom:8px;">
|
||||
{_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>
|
||||
<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>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding:28px 24px;">
|
||||
<div style="padding:32px 28px;color:#1e1b4b;">
|
||||
{body}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;">
|
||||
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p>
|
||||
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
|
||||
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
|
||||
</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:
|
||||
"""Send a branded test email."""
|
||||
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>
|
||||
"""
|
||||
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
|
||||
|
||||
@@ -29,10 +29,15 @@ def _note_to_item(note: Note) -> dict:
|
||||
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", "")
|
||||
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", "")
|
||||
elif note.entity_type == "list":
|
||||
# Parse markdown task list syntax into structured items
|
||||
body = note.body or ""
|
||||
@@ -46,6 +51,14 @@ def _note_to_item(note: Note) -> dict:
|
||||
item["item_count"] = len(list_items)
|
||||
item["checked_count"] = sum(1 for i in list_items if i["checked"])
|
||||
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
|
||||
|
||||
|
||||
@@ -69,17 +82,15 @@ async def query_knowledge(
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
base = (
|
||||
select(Note)
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None)) # exclude tasks
|
||||
)
|
||||
base = select(Note).where(Note.user_id == user_id)
|
||||
|
||||
if note_type:
|
||||
base = base.where(Note.note_type == note_type)
|
||||
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:
|
||||
# Exclude tasks — already done above; also exclude any legacy nulls
|
||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
# All types including tasks
|
||||
pass
|
||||
|
||||
for tag in tags:
|
||||
base = base.where(Note.tags.contains([tag]))
|
||||
@@ -111,31 +122,74 @@ async def _semantic_knowledge_search(
|
||||
limit: int,
|
||||
offset: 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:
|
||||
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(
|
||||
user_id=user_id,
|
||||
query=q,
|
||||
limit=min(200, limit * 8),
|
||||
limit=min(200, limit * 4),
|
||||
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:
|
||||
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True)
|
||||
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
|
||||
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
|
||||
|
||||
results = []
|
||||
for _score, note in candidates:
|
||||
if note_type and note.entity_type != note_type:
|
||||
continue
|
||||
if tags and not all(t in (note.tags or []) for t in tags):
|
||||
continue
|
||||
results.append(note)
|
||||
# 3. Merge — keyword matches first, then semantic (deduplicated)
|
||||
seen_ids: set[int] = set()
|
||||
merged: list[Note] = []
|
||||
for note in keyword_notes:
|
||||
if note.id not in seen_ids:
|
||||
seen_ids.add(note.id)
|
||||
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)
|
||||
page_items = results[offset: offset + limit]
|
||||
total = len(merged)
|
||||
page_items = merged[offset: offset + limit]
|
||||
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 = (
|
||||
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)
|
||||
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:
|
||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
pass
|
||||
stmt = base.distinct().order_by("tag")
|
||||
rows = list((await session.execute(stmt)).scalars().all())
|
||||
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]:
|
||||
"""Return per-type count of knowledge objects for the sidebar display."""
|
||||
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)
|
||||
@@ -170,11 +226,23 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
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 = {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"))
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
||||
return counts
|
||||
|
||||
|
||||
@@ -197,15 +265,14 @@ async def query_knowledge_ids(
|
||||
return [item["id"] for item in items], total
|
||||
|
||||
async with async_session() as session:
|
||||
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)
|
||||
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:
|
||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
pass
|
||||
for tag in tags:
|
||||
base = base.where(Note.tags.contains([tag]))
|
||||
|
||||
|
||||
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
|
||||
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:
|
||||
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
|
||||
import asyncio
|
||||
@@ -92,6 +108,7 @@ async def create_note(
|
||||
await session.refresh(note)
|
||||
|
||||
if project_id is not None:
|
||||
await _maybe_reactivate_project(project_id)
|
||||
await _maybe_trigger_project_summary(user_id, project_id)
|
||||
|
||||
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)
|
||||
|
||||
if note.project_id is not None:
|
||||
await _maybe_reactivate_project(note.project_id)
|
||||
await _maybe_trigger_project_summary(user_id, note.project_id)
|
||||
|
||||
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.
|
||||
</p>
|
||||
<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
|
||||
</a>
|
||||
</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.
|
||||
</p>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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:
|
||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
|
||||
Reference in New Issue
Block a user