Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28fae75258 | |||
| 058bd76719 | |||
| d56b57ee40 | |||
| 8762552234 | |||
| 6cbf9be052 | |||
| 3687fbeeb9 | |||
| a6543c1dc5 | |||
| 4558dd578a | |||
| 8647f52fbc | |||
| 304affb837 | |||
| 2e3f90384d | |||
| 16b9ed9392 | |||
| f9c6802939 | |||
| f2debd8a5b | |||
| f8f14eea0f | |||
| 64c19932a7 | |||
| 317d8f25d0 | |||
| 87f74b1cd5 | |||
| f7fda0adca | |||
| 0ca39a2e34 | |||
| 16d9af8b96 | |||
| 2ac894b5d1 | |||
| 7e81e50e3e |
@@ -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,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
|
||||
@@ -58,6 +58,14 @@
|
||||
--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"] {
|
||||
@@ -68,8 +76,8 @@
|
||||
--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-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;
|
||||
@@ -109,6 +117,13 @@
|
||||
--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);
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
@@ -153,7 +153,7 @@ router.afterEach(() => {
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
@@ -194,7 +194,7 @@ router.afterEach(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(124, 58, 237, 0.06);
|
||||
background: var(--color-primary-faint);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
@@ -217,13 +217,13 @@ router.afterEach(() => {
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.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);
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -387,7 +387,7 @@ router.afterEach(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
background: var(--color-primary-wash);
|
||||
box-shadow: none;
|
||||
}
|
||||
.mobile-user .btn-logout {
|
||||
|
||||
@@ -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, #7c3aed, #5b21b6);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,23 @@ 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')
|
||||
} 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 +107,7 @@ async function loadAll() {
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
loadCurrentConditions(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
@@ -198,11 +216,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 +260,15 @@ onMounted(async () => {
|
||||
<!-- Left column: Weather -->
|
||||
<div class="briefing-left">
|
||||
<div class="panel-label">Weather</div>
|
||||
<!-- Current conditions (live) -->
|
||||
<div v-if="currentConditions && currentConditions.temperature != null" class="current-conditions">
|
||||
<div class="cc-temp">{{ currentConditions.temperature }}°{{ currentConditions.temp_unit }}</div>
|
||||
<div class="cc-desc">{{ currentConditions.description }}</div>
|
||||
<div v-if="currentConditions.precip_next_3h?.some((p: number) => p > 0)" class="cc-precip">
|
||||
Rain next 3h: {{ currentConditions.precip_next_3h.map((p: number) => `${p}%`).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Forecast card -->
|
||||
<template v-if="weatherData.length">
|
||||
<WeatherCard
|
||||
v-for="loc in weatherData"
|
||||
@@ -457,6 +491,31 @@ onMounted(async () => {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Current conditions (live) ─────────────────────────── */
|
||||
.current-conditions {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cc-temp {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.cc-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cc-precip {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-accent-warm);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ 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 }[];
|
||||
@@ -418,12 +422,30 @@ onUnmounted(() => {
|
||||
<aside class="filter-panel">
|
||||
<!-- New note button -->
|
||||
<div class="new-note-wrap">
|
||||
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</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('task')">Task</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>
|
||||
|
||||
@@ -526,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>
|
||||
@@ -721,7 +745,7 @@ onUnmounted(() => {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.today-link {
|
||||
color: var(--color-primary, #7c3aed);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
@@ -765,51 +789,71 @@ onUnmounted(() => {
|
||||
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;
|
||||
border: 1px solid rgba(124, 58, 237, 0.4);
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: var(--color-primary, #a78bfa);
|
||||
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 { box-shadow: var(--glow-cta-hover); }
|
||||
.btn-new-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
}
|
||||
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
|
||||
.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(124, 58, 237, 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(124, 58, 237, 0.12); }
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
@@ -830,8 +874,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
||||
.filter-btn.active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: var(--color-primary, #a78bfa);
|
||||
background: var(--color-primary-wash);
|
||||
color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
.filter-btn-label { flex: 1; }
|
||||
@@ -848,7 +892,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
color: var(--color-primary, #a78bfa);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
|
||||
@@ -893,7 +937,7 @@ onUnmounted(() => {
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--color-primary, #7c3aed); }
|
||||
.search-input:focus { border-color: var(--color-primary); }
|
||||
.sort-select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
@@ -920,9 +964,9 @@ onUnmounted(() => {
|
||||
}
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
color: var(--color-primary, #a78bfa);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
@@ -954,16 +998,16 @@ onUnmounted(() => {
|
||||
}
|
||||
.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);
|
||||
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-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); }
|
||||
.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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -676,17 +676,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;
|
||||
@@ -857,7 +857,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 +866,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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