Compare commits

...

35 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:27:22 -04:00
bvandeusen 317d8f25d0 feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:07 -04:00
bvandeusen 87f74b1cd5 docs: add specialized note type editors implementation plan 2026-04-08 13:21:20 -04:00
bvandeusen f7fda0adca docs: add specialized note type editors design spec 2026-04-08 13:08:01 -04:00
bvandeusen 0ca39a2e34 feat(knowledge): redesign new item button as gradient CTA with icon menu for all 5 types 2026-04-08 12:34:52 -04:00
bvandeusen 16d9af8b96 fix(theme): increase dark mode contrast for borders, glows, and card edges; DRY color values into CSS variables 2026-04-08 12:22:03 -04:00
bvandeusen 2ac894b5d1 refactor(theme): DRY hardcoded violet values into CSS custom properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:19:38 -04:00
bvandeusen 7e81e50e3e feat(theme): sweep indigo to violet across all remaining views and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:12:46 -04:00
34 changed files with 2556 additions and 267 deletions
@@ -0,0 +1,786 @@
# Specialized Note Type Editors — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/views/NoteEditorView.vue` |
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `src/fabledassistant/services/knowledge.py` |
---
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
**Files:**
- Modify: `frontend/src/components/MarkdownToolbar.vue`
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
In `frontend/src/components/MarkdownToolbar.vue`, find:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
@mousedown.prevent="btn.command()"
>
```
Replace with:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
tabindex="-1"
@mousedown.prevent="btn.command()"
>
```
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
In `frontend/src/views/NoteEditorView.vue`, find the title input:
```html
<input
ref="titleRef"
v-model="title"
type="text"
placeholder="Title"
class="title-input"
```
Replace with:
```html
<input
ref="titleRef"
v-model="title"
type="text"
:placeholder="titlePlaceholder"
class="title-input"
```
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
```ts
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
```
- [ ] **Step 3: Auto-focus title on mount**
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
```ts
await nextTick();
titleRef.value?.focus();
```
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
```
---
### Task 2: Person editor — form-first layout
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
- [ ] **Step 1: Add the person form template**
In the template, find the `<!-- ── Main column ──` section. The current structure is:
```html
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row">
...
</div>
<!-- Streaming/Review/Normal editor templates -->
</div>
```
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
```html
<div class="note-main">
<!-- ── Person form ──────────────────────────────────────── -->
<template v-if="noteType === 'person'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- ── Generic note editor (existing) ───────────────────── -->
<template v-else-if="noteType === 'note'">
<!-- ... existing TipTap-first editor content stays here ... -->
</template>
</div>
```
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
- [ ] **Step 2: Add `notesExpanded` ref**
In the `<script setup>`, after the `sidebarOpen` ref, add:
```ts
const notesExpanded = ref(false);
```
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
```ts
notesExpanded.value = !!(store.currentNote.body || '').trim();
```
- [ ] **Step 3: Remove person fields from sidebar**
In the sidebar template, find:
```html
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
```
Delete this entire block.
- [ ] **Step 4: Add entity form CSS**
Add to the `<style scoped>` block:
```css
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
```
- [ ] **Step 5: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
```
---
### Task 3: Place editor + List builder
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
- [ ] **Step 1: Add place form template**
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── Place form ───────────────────────────────────────── -->
<template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 2: Remove place fields from sidebar**
Find and delete:
```html
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
```
- [ ] **Step 3: Add list item types and state**
In the `<script setup>`, after the `notesExpanded` ref, add:
```ts
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
```
- [ ] **Step 4: Initialize list items on mount**
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
```ts
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
```
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
```ts
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
```
- [ ] **Step 5: Update save to serialize list**
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
```ts
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
```
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
- [ ] **Step 6: Add list builder template**
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── List builder ─────────────────────────────────────── -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 7: Add list builder CSS**
Add to the `<style scoped>` block:
```css
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
```
- [ ] **Step 8: Handle the generic note template wrapper**
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
The final structure in `.note-main` should be:
```
<template v-if="noteType === 'person'"> ... </template>
<template v-else-if="noteType === 'place'"> ... </template>
<template v-else-if="noteType === 'list'"> ... </template>
<template v-else> ... existing TipTap editor ... </template>
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
```
---
### Task 4: Backend — new person/place fields in knowledge cards
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
- [ ] **Step 1: Update `_note_to_item` for person**
In `src/fabledassistant/services/knowledge.py`, find:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
```
Replace with:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
```
- [ ] **Step 2: Update `_note_to_item` for place**
Find:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
```
Replace with:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
```
- [ ] **Step 3: Update KnowledgeItem interface**
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
After `phone?: string;` add:
```ts
birthday?: string;
organization?: string;
```
After `hours?: string;` add:
```ts
website?: string;
category?: string;
```
- [ ] **Step 4: Update person card display**
In the template, find the person card specifics:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
Replace with:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
- [ ] **Step 5: Update place card display**
Find:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
Replace with:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
```bash
cd frontend && npx tsc --noEmit
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
```
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
```
@@ -0,0 +1,204 @@
# Specialized Note Type Editors — Design Spec
## Goal
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
## Architecture
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
## Person Editor
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
### Fields (in order, all in main content area)
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Name" | `title` |
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
| Birthday | date input | — | `entityMeta.birthday` (new field) |
| Email | email input | "email@example.com" | `entityMeta.email` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
### Notes section
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ Name: [________________________________] │
│ │
│ Relationship: [________________________] │
│ Birthday: [____date picker________] │
│ Email: [________________________] │
│ Phone: [________________________] │
│ Organization: [________________________] │
│ Address: [________________________] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (markdown body) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
### Data migration
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
## Place Editor
When `noteType === 'place'`, same form-first pattern.
### Fields
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Place name" | `title` |
| Address | text input | "Street, City, State" | `entityMeta.address` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Hours | text input | "e.g. MonFri 9am5pm" | `entityMeta.hours` |
| Website | url input | "https://..." | `entityMeta.website` (new field) |
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
### Notes section
Same as Person — collapsible TipTap editor below the form.
## List Editor
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
### List builder
Each list item is a row with:
- Checkbox (toggle checked state)
- Text input (item text, fills available width)
- Delete button (× icon, right side)
Below the items: an "Add item" button.
### Behavior
- **Enter** in any item input: creates a new item below and focuses it
- **Backspace** on an empty item: deletes the item and focuses the previous one
- **Checkbox toggle**: updates the item's checked state
- **Delete button**: removes the item
### Serialization
On save, list items are serialized to markdown checkbox format in the body:
```markdown
- [ ] Buy groceries
- [x] Call dentist
- [ ] Pick up prescription
```
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
### Notes section
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ List title: [____________________________│
│ │
│ [ ] Buy groceries [×] │
│ [x] Call dentist [×] │
│ [ ] Pick up prescription [×] │
│ │
│ [+ Add item] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (additional context) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
## Tab Navigation & Auto-Focus
### All note types
1. **On page load**: focus the title/name input automatically
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
- Note: TipTap editor body
- Person: Relationship field
- Place: Address field
- List: first list item (or "Add item" button if empty)
3. **Tab through fields**: natural order through all form fields
4. **Tab from last form field**: enter the Notes section (TipTap editor)
### Implementation
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
### Title placeholder by type
| Type | Placeholder |
|------|-------------|
| Note | "Title" |
| Person | "Name" |
| Place | "Place name" |
| List | "List title" |
| Task | "Title" (unchanged, task editor is separate) |
## Sidebar changes
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
## Backend changes
### New entity metadata fields
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
### Knowledge service
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
- Person: add `birthday`, `organization`, `address`
- Place: add `website`, `category`
### Knowledge card display
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
## Files changed
| File | Change |
|------|--------|
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
## What does NOT change
- Note model / database schema (entity_meta is already a JSON column)
- API endpoints (same CRUD)
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
- Backend storage format — entity_meta key-value pairs
+17 -2
View File
@@ -58,6 +58,14 @@
--color-accent-warm-light: #d4a017; --color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed; --color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6; --color-primary-deep: #5b21b6;
/* Reusable brand patterns */
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
--color-primary-faint: rgba(124, 58, 237, 0.08);
--color-primary-tint: rgba(124, 58, 237, 0.12);
--color-primary-wash: rgba(124, 58, 237, 0.20);
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -68,8 +76,8 @@
--color-text: #e4e4f0; --color-text: #e4e4f0;
--color-text-secondary: #8888a8; --color-text-secondary: #8888a8;
--color-text-muted: #52526a; --color-text-muted: #52526a;
--color-border: rgba(124, 58, 237, 0.12); --color-border: rgba(124, 58, 237, 0.22);
--color-input-border: rgba(124, 58, 237, 0.22); --color-input-border: rgba(124, 58, 237, 0.35);
--color-primary: #a78bfa; --color-primary: #a78bfa;
--color-danger: #f44336; --color-danger: #f44336;
--color-tag-bg: #2a2a45; --color-tag-bg: #2a2a45;
@@ -109,6 +117,13 @@
--color-accent-warm-light: #e8c45a; --color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed; --color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6; --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);
} }
*, *,
+9 -9
View File
@@ -153,7 +153,7 @@ router.afterEach(() => {
<style scoped> <style scoped>
.app-header { .app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg)); 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; position: relative;
} }
.nav { .nav {
@@ -194,7 +194,7 @@ router.afterEach(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
background: rgba(124, 58, 237, 0.06); background: var(--color-primary-faint);
border-radius: 10px; border-radius: 10px;
padding: 3px; padding: 3px;
} }
@@ -208,7 +208,7 @@ router.afterEach(() => {
} }
.nav-link { .nav-link {
color: var(--color-text-muted); color: var(--color-text-secondary);
text-decoration: none; text-decoration: none;
font-size: 0.82rem; font-size: 0.82rem;
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
@@ -216,14 +216,14 @@ router.afterEach(() => {
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.nav-link:hover { .nav-link:hover {
color: var(--color-text-secondary); color: var(--color-text);
background: rgba(124, 58, 237, 0.08); background: var(--color-primary-tint);
} }
.nav-link.router-link-active { .nav-link.router-link-active {
color: #c4b5fd; color: var(--color-primary-solid);
font-weight: 600; font-weight: 600;
background: rgba(124, 58, 237, 0.2); background: rgba(124, 58, 237, 0.25);
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2); box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
} }
/* Status indicator */ /* Status indicator */
@@ -387,7 +387,7 @@ router.afterEach(() => {
border-radius: 8px; border-radius: 8px;
} }
.mobile-menu .nav-link.router-link-active { .mobile-menu .nav-link.router-link-active {
background: rgba(124, 58, 237, 0.15); background: var(--color-primary-wash);
box-shadow: none; box-shadow: none;
} }
.mobile-user .btn-logout { .mobile-user .btn-logout {
@@ -247,7 +247,7 @@ async function finish() {
} }
.wizard-progress-fill { .wizard-progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #6366f1, #4f46e5); background: var(--gradient-cta);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.wizard-step-label { .wizard-step-label {
@@ -375,7 +375,7 @@ async function finish() {
} }
.btn-wizard-primary { .btn-wizard-primary {
padding: 0.5rem 1.25rem; padding: 0.5rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
+1 -1
View File
@@ -391,7 +391,7 @@ defineExpose({ focus, prefill })
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #7c3aed, #5b21b6); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
+105 -16
View File
@@ -64,36 +64,114 @@ function toIso(date: string, time: string): string {
return `${date}T${time}:00${sign}${h}:${min}`; return `${date}T${time}:00${sign}${h}:${min}`;
} }
// ── Time helpers ──────────────────────────────────────────────────────────────
/** Round up to next 30-minute boundary */
function nextRoundedTime(): string {
const now = new Date();
let h = now.getHours();
let m = now.getMinutes();
if (m <= 30) { m = 30; }
else { m = 0; h = (h + 1) % 24; }
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Add hours to a time string (HH:MM), returns HH:MM */
function addHours(time: string, hours: number): string {
const [h, m] = time.split(":").map(Number);
const totalMin = h * 60 + m + hours * 60;
const nh = Math.floor(totalMin / 60) % 24;
const nm = totalMin % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
/** Duration in minutes between two time strings */
function durationMin(start: string, end: string): number {
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
return (eh * 60 + em) - (sh * 60 + sm);
}
/** True if a date+time is in the past */
const isPastEvent = computed(() => {
if (allDay.value || !startDate.value || !startTime.value) return false;
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
return dt.getTime() < Date.now();
});
// Track duration so end moves with start
let _lastDurationMin = 60;
function resetForm() { function resetForm() {
if (props.event) { if (props.event) {
title.value = props.event.title; title.value = props.event.title;
allDay.value = props.event.all_day; allDay.value = props.event.all_day;
// All-day events: use UTC date string directly to avoid timezone shifting
// (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones)
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt); startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt); startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : ""; endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : ""; endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
description.value = props.event.description || ""; description.value = props.event.description || "";
location.value = props.event.location || ""; location.value = props.event.location || "";
color.value = props.event.color || ""; color.value = props.event.color || "";
projectId.value = props.event.project_id; projectId.value = props.event.project_id;
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
if (_lastDurationMin <= 0) _lastDurationMin = 60;
} else { } else {
title.value = ""; title.value = "";
allDay.value = false; allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10); const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
const roundedStart = nextRoundedTime();
startDate.value = base; startDate.value = base;
startTime.value = "09:00"; startTime.value = roundedStart;
endDate.value = base; endDate.value = base;
endTime.value = "10:00"; endTime.value = addHours(roundedStart, 1);
description.value = ""; description.value = "";
location.value = ""; location.value = "";
color.value = ""; color.value = "";
projectId.value = null; projectId.value = null;
_lastDurationMin = 60;
} }
deleteConfirm.value = false; deleteConfirm.value = false;
} }
// When start time changes, move end time to preserve duration
watch(startTime, (newStart) => {
if (allDay.value || !newStart) return;
endTime.value = addHours(newStart, _lastDurationMin / 60);
});
// When start date changes, move end date to match (preserve same-day or multi-day gap)
watch(startDate, (newDate) => {
if (!newDate) return;
endDate.value = newDate;
});
// When end time changes manually, update the tracked duration (but guard against end < start)
watch(endTime, (newEnd) => {
if (allDay.value || !newEnd || !startTime.value) return;
const dur = durationMin(startTime.value, newEnd);
if (dur <= 0) {
// Snap back to start + 1 hour
endTime.value = addHours(startTime.value, 1);
_lastDurationMin = 60;
} else {
_lastDurationMin = dur;
}
});
// All-day toggle: clear/restore times
watch(allDay, (isAllDay) => {
if (isAllDay) {
startTime.value = "";
endTime.value = "";
} else {
const rounded = nextRoundedTime();
startTime.value = rounded;
endTime.value = addHours(rounded, 1);
_lastDurationMin = 60;
}
});
watch(() => props.event, resetForm, { immediate: true }); watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm); watch(() => props.initialDate, resetForm);
@@ -113,6 +191,10 @@ async function save() {
toast.show("Start date is required", "error"); toast.show("Start date is required", "error");
return; return;
} }
if (!allDay.value && !startTime.value) {
toast.show("Start time is required", "error");
return;
}
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value); const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value const end_dt = endDate.value
@@ -199,18 +281,19 @@ async function doDelete() {
<!-- Start --> <!-- Start -->
<div class="so-field"> <div class="so-field">
<label class="so-label">Start</label> <label class="so-label">Start <span class="required">*</span></label>
<div class="dt-row"> <div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" /> <input v-model="startDate" type="date" class="so-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" /> <input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
</div> </div>
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
</div> </div>
<!-- End --> <!-- End -->
<div class="so-field"> <div class="so-field">
<label class="so-label">End <span class="so-hint">(optional)</span></label> <label class="so-label">End</label>
<div class="dt-row"> <div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" /> <input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" /> <input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
</div> </div>
</div> </div>
@@ -232,7 +315,7 @@ async function doDelete() {
<label class="so-label so-label-inline">Color</label> <label class="so-label so-label-inline">Color</label>
<div class="color-row"> <div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" /> <input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" /> <input v-model="color" class="so-input color-hex" placeholder="#7c3aed" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button> <button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button>
</div> </div>
</div> </div>
@@ -357,13 +440,19 @@ async function doDelete() {
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); } .so-input:focus { outline: none; border-color: var(--color-primary); }
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; } .so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; } .dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; } .dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; } .dt-time { width: 7.5rem; flex-shrink: 0; }
.so-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-accent-warm, #d4a017);
font-style: italic;
}
.toggle-btn { .toggle-btn {
background: var(--color-input-bg, #111113); background: var(--color-input-bg, #111113);
@@ -376,8 +465,8 @@ async function doDelete() {
transition: all 0.15s; transition: all 0.15s;
} }
.toggle-btn.active { .toggle-btn.active {
background: var(--color-primary, #6366f1); background: var(--color-primary);
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
color: #fff; color: #fff;
} }
@@ -403,7 +492,7 @@ async function doDelete() {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -94,10 +94,10 @@ const markers: Record<DiffLine["type"], string> = {
/* ── Streaming ── */ /* ── Streaming ── */
.iap-streaming { .iap-streaming {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
} }
.iap-streaming .iap-header { .iap-streaming .iap-header {
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, var(--color-bg-secondary)); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
} }
.iap-pulse { .iap-pulse {
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite; animation: iap-pulse 1.2s ease-in-out infinite;
} }
@@ -172,8 +172,8 @@ const markers: Record<DiffLine["type"], string> = {
font-family: inherit; font-family: inherit;
} }
.iap-btn-toggle:hover { .iap-btn-toggle:hover {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
color: var(--color-primary, #6366f1); color: var(--color-primary);
} }
.iap-actions { .iap-actions {
@@ -78,6 +78,7 @@ const groups = [
:class="['md-btn', { active: btn.isActive() }]" :class="['md-btn', { active: btn.isActive() }]"
:title="btn.title" :title="btn.title"
type="button" type="button"
tabindex="-1"
@mousedown.prevent="btn.command()" @mousedown.prevent="btn.command()"
> >
<span class="btn-icon" v-html="ICONS[btn.id]" /> <span class="btn-icon" v-html="ICONS[btn.id]" />
+3 -3
View File
@@ -64,11 +64,11 @@ function goEdit() {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
background: var(--color-bg-card); background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease; transition: box-shadow 0.2s, transform 0.18s ease;
} }
.note-card:hover { .note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -89,7 +89,7 @@ function goEdit() {
} }
.note-card.compact:hover { .note-card.compact:hover {
box-shadow: none; box-shadow: none;
background: rgba(99, 102, 241, 0.04); background: rgba(124, 58, 237, 0.04);
transform: none; transform: none;
} }
.note-title-compact { .note-title-compact {
+1 -1
View File
@@ -337,7 +337,7 @@ onMounted(async () => {
.btn-add-share { .btn-add-share {
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
+1 -1
View File
@@ -40,7 +40,7 @@ defineEmits<{
} }
.tag-pill:hover { .tag-pill:hover {
color: var(--color-primary); color: var(--color-primary);
background: rgba(99, 102, 241, 0.08); background: var(--color-primary-tint);
} }
.dismiss { .dismiss {
background: none; background: none;
+2 -2
View File
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
background: var(--color-bg-card); background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease; transition: box-shadow 0.2s, transform 0.18s ease;
} }
.task-card:hover { .task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
+4 -4
View File
@@ -751,7 +751,7 @@ function closeEventSlideOver(changed = false) {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -773,14 +773,14 @@ function closeEventSlideOver(changed = false) {
} }
.tool-event-card.clickable { cursor: pointer; } .tool-event-card.clickable { cursor: pointer; }
.tool-event-card.clickable:hover { .tool-event-card.clickable:hover {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card, #16161a));
} }
.tool-event-card-dot { .tool-event-card-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 3px; margin-top: 3px;
} }
+34 -2
View File
@@ -60,6 +60,27 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const weatherData = ref<WeatherData[]>([]) const weatherData = ref<WeatherData[]>([])
const tempUnit = ref<string>('C') const tempUnit = ref<string>('C')
interface CurrentConditions {
temperature: number | null;
windspeed: number | null;
description: string;
precip_next_3h: number[];
temp_unit: string;
location: string;
}
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
// Patch the live temperature into the WeatherCard so it stays fresh
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent — endpoint may not have locations configured */ }
}
async function loadWeather() { async function loadWeather() {
try { try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather') const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
@@ -90,6 +111,7 @@ async function loadAll() {
getBriefingToday().catch(() => null), getBriefingToday().catch(() => null),
loadWeather(), loadWeather(),
loadNews(), loadNews(),
loadCurrentConditions(),
]) ])
conversations.value = convList conversations.value = convList
if (today) { if (today) {
@@ -198,11 +220,18 @@ useBackgroundRefresh(
) )
let _mounted = true let _mounted = true
onUnmounted(() => { _mounted = false }) onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => { onMounted(async () => {
await checkSetup() await checkSetup()
if (!showWizard.value) await loadAll() if (!showWizard.value) {
await loadAll()
// Poll current conditions every 30 minutes
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
}
}) })
</script> </script>
@@ -235,6 +264,7 @@ onMounted(async () => {
<!-- Left column: Weather --> <!-- Left column: Weather -->
<div class="briefing-left"> <div class="briefing-left">
<div class="panel-label">Weather</div> <div class="panel-label">Weather</div>
<!-- Forecast card -->
<template v-if="weatherData.length"> <template v-if="weatherData.length">
<WeatherCard <WeatherCard
v-for="loc in weatherData" v-for="loc in weatherData"
@@ -457,6 +487,8 @@ onMounted(async () => {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* ── Current conditions (live) ─────────────────────────── */
.panel-empty { .panel-empty {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--color-text-muted); color: var(--color-text-muted);
+10 -10
View File
@@ -435,7 +435,7 @@ const upcomingGrouped = computed(() => {
} }
.btn-new-event { .btn-new-event {
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -482,8 +482,8 @@ const upcomingGrouped = computed(() => {
} }
:deep(.fc-button:hover), :deep(.fc-button:hover),
:deep(.fc-button-active) { :deep(.fc-button-active) {
background: var(--color-primary, #6366f1) !important; background: var(--color-primary) !important;
border-color: var(--color-primary, #6366f1) !important; border-color: var(--color-primary) !important;
color: #fff !important; color: #fff !important;
} }
:deep(.fc-button:focus) { box-shadow: none !important; } :deep(.fc-button:focus) { box-shadow: none !important; }
@@ -494,7 +494,7 @@ const upcomingGrouped = computed(() => {
font-size: 0.82rem; font-size: 0.82rem;
} }
:deep(.fc-daygrid-day.fc-day-today) { :deep(.fc-daygrid-day.fc-day-today) {
background: rgba(99, 102, 241, 0.08); background: var(--color-primary-tint);
} }
:deep(.fc-event) { :deep(.fc-event) {
border-radius: 4px; border-radius: 4px;
@@ -505,7 +505,7 @@ const upcomingGrouped = computed(() => {
} }
:deep(.fc-event-main) { color: #fff; } :deep(.fc-event-main) { color: #fff; }
:deep(.fc-event:not([style*="background"])) { :deep(.fc-event:not([style*="background"])) {
background: var(--color-primary, #6366f1); background: var(--color-primary);
} }
:deep(.fc-daygrid-day-frame) { min-height: 5rem; } :deep(.fc-daygrid-day-frame) { min-height: 5rem; }
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); } :deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
@@ -574,8 +574,8 @@ const upcomingGrouped = computed(() => {
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
} }
.picker-month.active { .picker-month.active {
background: rgba(99,102,241,0.22); background: rgba(124,58,237,0.22);
color: var(--color-primary, #818cf8); color: var(--color-primary);
font-weight: 700; font-weight: 700;
} }
@@ -634,7 +634,7 @@ const upcomingGrouped = computed(() => {
.upcoming-card-accent { .upcoming-card-accent {
width: 4px; width: 4px;
flex-shrink: 0; flex-shrink: 0;
background: var(--ev-color, #6366f1); background: var(--ev-color, #7c3aed);
} }
.upcoming-card-body { .upcoming-card-body {
@@ -691,7 +691,7 @@ const upcomingGrouped = computed(() => {
.popover-accent { .popover-accent {
height: 4px; height: 4px;
background: var(--color-primary, #6366f1); background: var(--color-primary);
} }
.popover-content { .popover-content {
@@ -750,7 +750,7 @@ const upcomingGrouped = computed(() => {
} }
.popover-btn:hover { opacity: 0.85; } .popover-btn:hover { opacity: 0.85; }
.popover-btn--edit { .popover-btn--edit {
background: var(--color-primary, #6366f1); background: var(--color-primary);
color: #fff; color: #fff;
} }
.popover-btn--close { .popover-btn--close {
+9 -9
View File
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
box-shadow: 0 2px 16px rgba(99, 102, 241, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
} }
.hero-top { .hero-top {
display: flex; display: flex;
@@ -605,20 +605,20 @@ function formatUpcomingTime(event: EventEntry): string {
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
padding: 0.55rem 1.1rem; padding: 0.55rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.3); box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
transition: opacity 0.15s, box-shadow 0.15s; transition: opacity 0.15s, box-shadow 0.15s;
flex-shrink: 0; flex-shrink: 0;
} }
.btn-workspace-hero:hover { .btn-workspace-hero:hover {
opacity: 0.9; opacity: 0.9;
box-shadow: 0 3px 14px rgba(99, 102, 241, 0.45); box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
} }
.hero-milestones { margin-bottom: 0.75rem; } .hero-milestones { margin-bottom: 0.75rem; }
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
font-weight: 500; font-weight: 500;
} }
.urgency-in-progress { .urgency-in-progress {
background: color-mix(in srgb, #6366f1 15%, transparent); background: color-mix(in srgb, #7c3aed 15%, transparent);
color: #6366f1; color: #7c3aed;
} }
.urgency-todo { .urgency-todo {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
@@ -892,14 +892,14 @@ function formatUpcomingTime(event: EventEntry): string {
width: 100%; width: 100%;
} }
.upcoming-event-card:hover { .upcoming-event-card:hover {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
} }
.upcoming-event-dot { .upcoming-event-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary, #6366f1); background: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 4px; margin-top: 4px;
} }
+84 -40
View File
@@ -27,8 +27,12 @@ interface KnowledgeItem {
relationship?: string; relationship?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthday?: string;
organization?: string;
address?: string; address?: string;
hours?: string; hours?: string;
website?: string;
category?: string;
item_count?: number; item_count?: number;
checked_count?: number; checked_count?: number;
list_items?: { text: string; checked: boolean }[]; list_items?: { text: string; checked: boolean }[];
@@ -418,12 +422,30 @@ onUnmounted(() => {
<aside class="filter-panel"> <aside class="filter-panel">
<!-- New note button --> <!-- New note button -->
<div class="new-note-wrap"> <div class="new-note-wrap">
<button class="btn-new-note" @click="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"> <div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('task')">Task</button> <button @click="createNew('note')">
<button @click="createNew('person')">Person</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<button @click="createNew('place')">Place</button> Note
<button @click="createNew('list')">List</button> </button>
<button @click="createNew('task')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Task
</button>
<button @click="createNew('person')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Person
</button>
<button @click="createNew('place')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
Place
</button>
<button @click="createNew('list')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
List
</button>
</div> </div>
</div> </div>
@@ -526,11 +548,13 @@ onUnmounted(() => {
<!-- Person specifics --> <!-- Person specifics -->
<div v-if="item.note_type === 'person'" class="k-card-meta"> <div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span> <span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span> <span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div> </div>
<!-- Place specifics --> <!-- Place specifics -->
<div v-else-if="item.note_type === 'place'" class="k-card-meta"> <div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span> <span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span> <span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div> </div>
@@ -721,7 +745,7 @@ onUnmounted(() => {
font-size: 0.78rem; font-size: 0.78rem;
} }
.today-link { .today-link {
color: var(--color-primary, #7c3aed); color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
opacity: 0.85; opacity: 0.85;
@@ -765,51 +789,71 @@ onUnmounted(() => {
margin-bottom: 6px; margin-bottom: 6px;
padding: 0 4px; padding: 0 4px;
} }
/* ── New note button ─────────────────────────────────────── */ /* ── New item button ─────────────────────────────────────── */
.new-note-wrap { .new-note-wrap {
position: relative; position: relative;
display: flex;
margin-bottom: 16px; margin-bottom: 16px;
} }
.btn-new-note { .btn-new-note {
flex: 1; width: 100%;
padding: 7px 10px; display: flex;
border-radius: 8px; align-items: center;
border: 1px solid rgba(124, 58, 237, 0.4); gap: 6px;
background: rgba(124, 58, 237, 0.12); padding: 8px 12px;
color: var(--color-primary, #a78bfa); border-radius: 10px;
border: none;
background: var(--gradient-cta);
color: #fff;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 600;
text-align: left; transition: box-shadow 0.15s;
transition: background 0.15s; }
.btn-new-note:hover { 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 { .new-note-menu {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 6px);
left: 0; left: 0;
right: 0; right: 0;
background: var(--color-bg-tertiary, #1a1b1e); background: var(--color-bg-card);
border: 1px solid rgba(124, 58, 237, 0.3); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 10px;
overflow: hidden; overflow: hidden;
z-index: 50; z-index: 50;
box-shadow: 0 4px 20px rgba(0,0,0,0.4); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
padding: 4px 0;
} }
.new-note-menu button { .new-note-menu button {
display: block; display: flex;
align-items: center;
gap: 10px;
width: 100%; width: 100%;
padding: 7px 12px; padding: 9px 14px;
background: none; background: none;
border: none; border: none;
color: var(--color-text); color: var(--color-text);
cursor: pointer; cursor: pointer;
font-size: 0.84rem; font-size: 0.84rem;
text-align: left; text-align: left;
transition: background 0.12s; transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--color-primary-tint);
color: var(--color-primary);
}
.new-note-menu button svg {
flex-shrink: 0;
opacity: 0.6;
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--color-primary);
} }
.new-note-menu button:hover { background: rgba(124, 58, 237, 0.12); }
.filter-btn { .filter-btn {
display: flex; display: flex;
@@ -830,8 +874,8 @@ onUnmounted(() => {
} }
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; } .filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
.filter-btn.active { .filter-btn.active {
background: rgba(124, 58, 237, 0.15); background: var(--color-primary-wash);
color: var(--color-primary, #a78bfa); color: var(--color-primary);
opacity: 1; opacity: 1;
} }
.filter-btn-label { flex: 1; } .filter-btn-label { flex: 1; }
@@ -848,7 +892,7 @@ onUnmounted(() => {
} }
.filter-btn.active .filter-count { .filter-btn.active .filter-count {
background: rgba(124, 58, 237, 0.2); background: rgba(124, 58, 237, 0.2);
color: var(--color-primary, #a78bfa); color: var(--color-primary);
} }
.filter-tag { font-size: 0.78rem; } .filter-tag { font-size: 0.78rem; }
@@ -893,7 +937,7 @@ onUnmounted(() => {
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.search-input:focus { border-color: var(--color-primary, #7c3aed); } .search-input:focus { border-color: var(--color-primary); }
.sort-select { .sort-select {
padding: 7px 10px; padding: 7px 10px;
border-radius: 8px; 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:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
.btn-graph.active { .btn-graph.active {
background: rgba(124, 58, 237, 0.15); background: var(--color-primary-wash);
border-color: rgba(124, 58, 237, 0.35); border-color: rgba(124, 58, 237, 0.35);
color: var(--color-primary, #a78bfa); color: var(--color-primary);
} }
/* ── Card grid ───────────────────────────────────────────── */ /* ── Card grid ───────────────────────────────────────────── */
@@ -954,16 +998,16 @@ onUnmounted(() => {
} }
.k-card:hover { .k-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3); 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.2); border-color: rgba(124, 58, 237, 0.35);
} }
/* Type-specific card DNA */ /* Type-specific card DNA */
.k-card--note { border-color: rgba(124, 58, 237, 0.12); } .k-card--note { border-color: rgba(124, 58, 237, 0.20); }
.k-card--task { border-color: rgba(212, 160, 23, 0.10); } .k-card--task { border-color: rgba(212, 160, 23, 0.18); }
.k-card--person { border-color: rgba(16, 185, 129, 0.10); } .k-card--person { border-color: rgba(16, 185, 129, 0.18); }
.k-card--place { border-color: rgba(245, 158, 11, 0.10); } .k-card--place { border-color: rgba(245, 158, 11, 0.18); }
.k-card--list { border-color: rgba(56, 189, 248, 0.10); } .k-card--list { border-color: rgba(56, 189, 248, 0.18); }
/* Top gradient bars */ /* Top gradient bars */
.k-card--note::before, .k-card--note::before,
+403 -71
View File
@@ -38,6 +38,81 @@ const dirty = ref(false);
const saving = ref(false); const saving = ref(false);
const showPreview = ref(false); const showPreview = ref(false);
const sidebarOpen = ref(true); const sidebarOpen = ref(true);
const notesExpanded = ref(false);
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(_index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null); const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null); const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => { const tiptapEditor = computed<Editor | null>(() => {
@@ -49,6 +124,15 @@ const noteId = computed(() =>
); );
const isEditing = computed(() => noteId.value !== null); const isEditing = computed(() => noteId.value !== null);
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
const renderedPreview = computed(() => renderMarkdown(body.value)); const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist — pass noteId for draft persistence // AI Assist — pass noteId for draft persistence
@@ -219,6 +303,13 @@ onMounted(async () => {
milestoneId.value = store.currentNote.milestone_id ?? null; milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note"; noteType.value = (store.currentNote.note_type as NoteType) || "note";
Object.assign(entityMeta, store.currentNote.metadata || {}); Object.assign(entityMeta, store.currentNote.metadata || {});
notesExpanded.value = !!(store.currentNote.body || '').trim();
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value]; savedTags = [...tags.value];
@@ -233,6 +324,9 @@ onMounted(async () => {
if (qt && ["note", "person", "place", "list"].includes(qt)) { if (qt && ["note", "person", "place", "list"].includes(qt)) {
noteType.value = qt as NoteType; noteType.value = qt as NoteType;
} }
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
} }
// Restore pending draft if any // Restore pending draft if any
@@ -243,6 +337,9 @@ onMounted(async () => {
// No draft — normal // No draft — normal
} }
await nextTick();
titleRef.value?.focus();
// Initial link suggestions // Initial link suggestions
fetchLinkSuggestions(); fetchLinkSuggestions();
}); });
@@ -250,11 +347,12 @@ onMounted(async () => {
async function save() { async function save() {
if (saving.value) return; if (saving.value) return;
saving.value = true; saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
try { try {
if (isEditing.value) { if (isEditing.value) {
await store.updateNote(noteId.value!, { await store.updateNote(noteId.value!, {
title: title.value, title: title.value,
body: body.value, body: finalBody,
tags: tags.value, tags: tags.value,
project_id: projectId.value, project_id: projectId.value,
milestone_id: milestoneId.value, milestone_id: milestoneId.value,
@@ -273,7 +371,7 @@ async function save() {
} else { } else {
const note = await store.createNote({ const note = await store.createNote({
title: title.value, title: title.value,
body: body.value, body: finalBody,
tags: tags.value, tags: tags.value,
project_id: projectId.value, project_id: projectId.value,
milestone_id: milestoneId.value, milestone_id: milestoneId.value,
@@ -304,7 +402,7 @@ async function confirmDelete() {
await store.deleteNote(noteId.value); await store.deleteNote(noteId.value);
dirty.value = false; dirty.value = false;
toast.show("Note deleted"); toast.show("Note deleted");
router.push("/notes"); router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch { } catch {
toast.show("Failed to delete note", "error"); toast.show("Failed to delete note", "error");
} }
@@ -314,9 +412,10 @@ async function confirmDelete() {
async function doAutoSave() { async function doAutoSave() {
if (!isEditing.value || saving.value) return; if (!isEditing.value || saving.value) return;
saving.value = true; saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
try { try {
await store.updateNote(noteId.value!, { await store.updateNote(noteId.value!, {
title: title.value, body: body.value, tags: tags.value, title: title.value, body: finalBody, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value, project_id: projectId.value, milestone_id: milestoneId.value,
note_type: noteType.value, metadata: { ...entityMeta }, note_type: noteType.value, metadata: { ...entityMeta },
}); });
@@ -346,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
<main class="editor-page note-editor-page"> <main class="editor-page note-editor-page">
<div class="editor-header"> <div class="editor-header">
<div class="toolbar"> <div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link> <router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving"> <button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
</button> </button>
@@ -357,7 +456,7 @@ onUnmounted(() => assist.clearSelection());
ref="titleRef" ref="titleRef"
v-model="title" v-model="title"
type="text" type="text"
placeholder="Title" :placeholder="titlePlaceholder"
class="title-input" class="title-input"
@input="markDirty" @input="markDirty"
@keydown.ctrl.s.prevent="save" @keydown.ctrl.s.prevent="save"
@@ -370,48 +469,181 @@ onUnmounted(() => assist.clearSelection());
<!-- Main column --> <!-- Main column -->
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"> <div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row"> <!-- Person form -->
<div class="editor-tabs"> <template v-if="noteType === 'person'">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button> <div class="entity-form">
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button> <div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div> </div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" /> <div class="notes-section">
</div> <button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
<!-- Streaming preview --> </button>
<template v-if="assist.state.value === 'streaming'"> <div v-if="notesExpanded" class="notes-editor-wrap">
<div class="stream-label">Generating...</div> <MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<TiptapEditor <TiptapEditor
ref="editorRef" ref="editorRef"
:modelValue="body" :modelValue="body"
placeholder="Write your note in Markdown..." placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate" @update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()" @escape="titleRef?.focus()"
/> />
</div> </div>
<div </div>
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</template> </template>
<!-- Error from assist --> <!-- Place form -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div> <template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- List builder -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- Generic note editor -->
<template v-else>
<div class="body-tabs-row">
<div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</template>
<!-- Error from assist -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
</template>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
@@ -476,38 +708,6 @@ onUnmounted(() => assist.clearSelection());
</select> </select>
</div> </div>
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
<!-- Link Suggestions --> <!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field"> <div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row"> <div class="sb-label-row">
@@ -731,7 +931,7 @@ onUnmounted(() => assist.clearSelection());
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.sb-select:focus, .sb-input:focus { .sb-select:focus, .sb-input:focus {
border-color: var(--color-primary, #6366f1); border-color: var(--color-primary);
} }
/* Tag suggest row inside sidebar */ /* Tag suggest row inside sidebar */
@@ -819,6 +1019,138 @@ onUnmounted(() => assist.clearSelection());
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow screen: sidebar collapses */ /* Narrow screen: sidebar collapses */
@media (max-width: 720px) { @media (max-width: 720px) {
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; } .note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+3 -3
View File
@@ -350,17 +350,17 @@ async function convertToTask() {
padding: 0.45rem 1.1rem; padding: 0.45rem 1.1rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-edit:hover { .btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); box-shadow: var(--glow-cta-hover);
opacity: 0.95; opacity: 0.95;
color: #fff; color: #fff;
} }
+52 -3
View File
@@ -20,7 +20,7 @@ interface Project {
title: string; title: string;
description: string | null; description: string | null;
goal: string | null; goal: string | null;
status: "active" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null; auto_summary: string | null;
permission?: string; permission?: string;
@@ -40,7 +40,7 @@ const toast = useToastStore();
const projects = ref<Project[]>([]); const projects = ref<Project[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all"); const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
// New project modal // New project modal
const showNewProjectModal = ref(false); const showNewProjectModal = ref(false);
@@ -103,6 +103,7 @@ async function createProject() {
function statusLabel(status: Project["status"]): string { function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active"; if (status === "active") return "Active";
if (status === "paused") return "Paused";
if (status === "completed") return "Completed"; if (status === "completed") return "Completed";
if (status === "archived") return "Archived"; if (status === "archived") return "Archived";
return status; return status;
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
if (!text) return ""; if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text; return text.length > max ? text.slice(0, max) + "..." : text;
} }
function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 };
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
return { total, pct };
}
</script> </script>
<template> <template>
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
<!-- Filter tabs --> <!-- Filter tabs -->
<div class="filter-tabs"> <div class="filter-tabs">
<button <button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const" v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
:key="tab" :key="tab"
:class="['tab-btn', { active: activeTab === tab }]" :class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab" @click="activeTab = tab"
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
</p> </p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p> <p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Overall completion bar -->
<div
v-if="overallPct(project).total > 0"
class="overall-bar-row"
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
>
<div class="overall-bar-track">
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
</div>
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
</div>
<!-- Milestone progress bars --> <!-- Milestone progress bars -->
<div <div
v-if="project.summary?.milestone_summary?.length" v-if="project.summary?.milestone_summary?.length"
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
line-height: 1.45; line-height: 1.45;
} }
.overall-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
margin-top: 0.1rem;
}
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--color-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
font-weight: 600;
}
.milestone-bars { .milestone-bars {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+19 -7
View File
@@ -23,7 +23,7 @@ interface Project {
title: string; title: string;
description: string | null; description: string | null;
goal: string | null; goal: string | null;
status: "active" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null; auto_summary: string | null;
permission?: string; permission?: string;
@@ -118,6 +118,14 @@ function toggleMilestoneCollapse(id: number) {
} }
} }
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
}
}
async function loadProject() { async function loadProject() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -130,6 +138,7 @@ async function loadProject() {
editStatus.value = data.status; editStatus.value = data.status;
editDirty.value = false; editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? []; milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
} catch { } catch {
error.value = "Failed to load project."; error.value = "Failed to load project.";
} finally { } finally {
@@ -143,6 +152,7 @@ async function loadMilestones() {
`/api/projects/${projectId.value}/milestones` `/api/projects/${projectId.value}/milestones`
); );
milestones.value = data.milestones; milestones.value = data.milestones;
autoCollapseCompleted(milestones.value);
} catch { } catch {
// silent // silent
} }
@@ -395,6 +405,7 @@ async function confirmDelete() {
<label class="edit-label">Status</label> <label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select"> <select v-model="editStatus" class="edit-select">
<option value="active">Active</option> <option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
@@ -676,17 +687,17 @@ async function confirmDelete() {
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-workspace:hover { box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); opacity: 0.95; color: #fff; } .btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
.btn-share { .btn-share {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
@@ -752,6 +763,7 @@ async function confirmDelete() {
flex-shrink: 0; flex-shrink: 0;
} }
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); } .status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); } .status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); } .status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
@@ -857,7 +869,7 @@ async function confirmDelete() {
.btn-save-panel { .btn-save-panel {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -866,10 +878,10 @@ async function confirmDelete() {
font-weight: 600; font-weight: 600;
font-family: inherit; font-family: inherit;
width: 100%; width: 100%;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25); box-shadow: var(--glow-soft);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); opacity: 0.95; } .btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); opacity: 0.95; }
.btn-save-panel:disabled { opacity: 0.45; cursor: default; } .btn-save-panel:disabled { opacity: 0.45; cursor: default; }
/* ── Content area ────────────────────────────────────────────── */ /* ── Content area ────────────────────────────────────────────── */
+2 -2
View File
@@ -3024,7 +3024,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
} }
.sidebar-item.active { .sidebar-item.active {
color: var(--color-primary); color: var(--color-primary);
background: rgba(99, 102, 241, 0.08); background: rgba(124, 58, 237, 0.08);
border-left-color: var(--color-primary); border-left-color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }
@@ -3489,7 +3489,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
} }
.sidebar-item.active { .sidebar-item.active {
border-bottom-color: var(--color-primary); border-bottom-color: var(--color-primary);
background: rgba(99, 102, 241, 0.08); background: var(--color-primary-tint);
} }
.settings-grid { .settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+2 -2
View File
@@ -362,7 +362,7 @@ async function confirmDelete() {
await store.deleteTask(taskId.value); await store.deleteTask(taskId.value);
dirty.value = false; dirty.value = false;
toast.show("Task deleted"); toast.show("Task deleted");
router.push("/tasks"); router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch { } catch {
toast.show("Failed to delete task", "error"); toast.show("Failed to delete task", "error");
} }
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
<main class="editor-page task-editor-page"> <main class="editor-page task-editor-page">
<div class="editor-header"> <div class="editor-header">
<div class="toolbar"> <div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link> <router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving"> <button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
</button> </button>
+4 -4
View File
@@ -480,23 +480,23 @@ const subTaskProgress = computed(() => {
padding: 0.45rem 1.1rem; padding: 0.45rem 1.1rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-edit:hover { .btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); box-shadow: var(--glow-cta-hover);
opacity: 0.95; opacity: 0.95;
color: #fff; color: #fff;
} }
.btn-advance { .btn-advance {
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--gradient-cta);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
+141
View File
@@ -155,6 +155,44 @@ async def get_weather():
return jsonify({"locations": cards, "temp_unit": temp_unit}) return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"]) @briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE @_REQUIRE
async def geocode_location(): async def geocode_location():
@@ -506,3 +544,106 @@ async def discuss_article(item_id: int):
)) ))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202 return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
user_prompt,
think=True,
))
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202
+3 -3
View File
@@ -52,7 +52,7 @@ async def create_project_route():
if not data.get("title"): if not data.get("title"):
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
status = data.get("status", "active") status = data.get("status", "active")
if status not in ("active", "completed", "archived"): if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project( project = await create_project(
uid, uid,
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"} allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed} fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "completed", "archived"): if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields) project = await update_project(uid, project_id, **fields)
if project is None: if project is None:
+414 -33
View File
@@ -23,14 +23,25 @@ SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def format_task(task: dict) -> str: def format_task(task: dict, today: str | None = None) -> str:
parts = [task.get("title", "Untitled")] parts = [task.get("title", "Untitled")]
if task.get("status"): if task.get("status"):
parts.append(f"[{task['status'].replace('_', ' ').title()}]") parts.append(f"[{task['status'].replace('_', ' ').title()}]")
if task.get("due_date"): if task.get("due_date"):
parts.append(f"due {task['due_date']}") parts.append(f"due {task['due_date']}")
if today and task["due_date"] < today and task.get("status") not in ("done", "cancelled"):
from datetime import date
try:
due = date.fromisoformat(task["due_date"])
td = date.fromisoformat(today)
days = (td - due).days
parts.append(f"({days} day{'s' if days != 1 else ''} overdue)")
except ValueError:
pass
if task.get("priority") and task["priority"] not in ("none", ""): if task.get("priority") and task["priority"] not in ("none", ""):
parts.append(f"priority: {task['priority']}") parts.append(f"priority: {task['priority']}")
if task.get("project_title"):
parts.append(f"project: {task['project_title']}")
return "".join(parts) return "".join(parts)
@@ -134,6 +145,13 @@ async def _gather_internal(user_id: int) -> dict:
all_tasks: list[dict] = [] all_tasks: list[dict] = []
try: try:
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100) all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
# Build project title lookup for enrichment
project_titles: dict[int, str] = {}
try:
for p in await list_projects(user_id):
project_titles[p.id] = p.title or "Untitled"
except Exception:
pass
all_tasks = [ all_tasks = [
{ {
"task_id": t.id, "task_id": t.id,
@@ -141,21 +159,22 @@ async def _gather_internal(user_id: int) -> dict:
"status": t.status, "status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None, "due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority, "priority": t.priority,
"project_title": project_titles.get(t.project_id) if t.project_id else None,
} }
for t in all_task_objs for t in all_task_objs
] ]
overdue = [ overdue = [
format_task(t) for t in all_tasks format_task(t, today) for t in all_tasks
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done" if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled")
] ]
due_today = [ due_today = [
format_task(t) for t in all_tasks format_task(t, today) for t in all_tasks
if t.get("due_date") == today and t.get("status") != "done" if t.get("due_date") == today and t.get("status") not in ("done", "cancelled")
] ]
high_priority = [ high_priority = [
format_task(t) for t in all_tasks format_task(t, today) for t in all_tasks
if t.get("priority") == "high" and t.get("status") not in ("done",) and if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and
format_task(t) not in overdue and format_task(t) not in due_today format_task(t, today) not in overdue and format_task(t, today) not in due_today
][:5] ][:5]
except Exception: except Exception:
logger.warning("Failed to gather tasks for briefing", exc_info=True) logger.warning("Failed to gather tasks for briefing", exc_info=True)
@@ -163,6 +182,7 @@ async def _gather_internal(user_id: int) -> dict:
# Calendar events today — internal store # Calendar events today — internal store
calendar_events: list[str] = [] calendar_events: list[str] = []
internal_events: list[dict] = []
try: try:
from fabledassistant.services.events import list_events as list_internal_events from fabledassistant.services.events import list_events as list_internal_events
today_date = datetime.now(user_tz).date() today_date = datetime.now(user_tz).date()
@@ -197,6 +217,87 @@ async def _gather_internal(user_id: int) -> dict:
except Exception: except Exception:
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True) logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
# Weather × calendar cross-reference
weather_warnings: list[str] = []
outdoor_keywords = {"park", "field", "patio", "garden", "outdoor", "trail", "beach",
"pool", "stadium", "yard", "lawn", "hike", "walk", "run", "bike",
"soccer", "baseball", "football", "tennis", "golf", "playground"}
try:
import json as _json
raw_config = await get_setting(user_id, "briefing_config", "{}")
config = _json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
locations = config.get("locations", {})
loc = locations.get("home") or locations.get("work")
if loc and loc.get("lat") and loc.get("lon") and internal_events:
from fabledassistant.services.weather import fetch_hourly_precip
hourly_precip = await fetch_hourly_precip(loc["lat"], loc["lon"])
if hourly_precip:
for e in internal_events:
if e.get("all_day"):
continue
# Check if event title or location contains outdoor keywords
event_text = f"{e.get('title', '')} {e.get('location', '')}".lower()
if not any(kw in event_text for kw in outdoor_keywords):
continue
# Get the event's start hour
start = e.get("start_dt")
if not start:
continue
from datetime import datetime as _dt
if isinstance(start, str):
start = _dt.fromisoformat(start)
local_start = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
# Check precip probability around the event time (hour before through event)
hour_key = local_start.strftime("%Y-%m-%dT%H:00")
precip = hourly_precip.get(hour_key, 0)
if precip >= 30:
time_str = local_start.strftime("%-I:%M %p")
weather_warnings.append(
f"Rain likely ({precip}%) around {time_str} when '{e.get('title', 'Event')}' "
f"is scheduled — consider an umbrella or backup plan"
)
except Exception:
logger.debug("Weather cross-reference failed", exc_info=True)
# Recent activity — what was the user working on yesterday?
recent_activity: list[str] = []
stale_projects: list[str] = []
try:
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
yesterday = datetime.now(user_tz) - __import__('datetime').timedelta(days=1)
async with async_session() as session:
# Notes/tasks updated in last 24h, grouped by project
result = await session.execute(
_sel(Project.title, _func.count(Note.id))
.join(Note, Note.project_id == Project.id)
.where(Note.user_id == user_id)
.where(Note.updated_at >= yesterday)
.group_by(Project.title)
.order_by(_func.count(Note.id).desc())
.limit(3)
)
for proj_title, count in result.all():
recent_activity.append(f"{proj_title}: {count} item{'s' if count != 1 else ''} updated")
# Stale projects — active projects with no note/task activity in 7+ days
week_ago = datetime.now(user_tz) - __import__('datetime').timedelta(days=7)
active_projects_q = await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)
for p in active_projects_q.scalars().all():
latest = await session.execute(
_sel(_func.max(Note.updated_at))
.where(Note.project_id == p.id, Note.user_id == user_id)
)
last_activity = latest.scalar()
if last_activity is None or last_activity < week_ago:
stale_projects.append(p.title or "Untitled")
except Exception:
logger.debug("Failed to gather recent activity for briefing", exc_info=True)
# Projects: active projects # Projects: active projects
projects_summary = [] projects_summary = []
try: try:
@@ -206,6 +307,51 @@ async def _gather_internal(user_id: int) -> dict:
except Exception: except Exception:
logger.warning("Failed to gather projects for briefing", exc_info=True) logger.warning("Failed to gather projects for briefing", exc_info=True)
# Build prioritized "Your Day" focus list
# Priority: overdue > due today > high priority in-progress > other in-progress
focus_tasks: list[str] = []
seen_titles: set[str] = set()
for t in sorted(all_tasks, key=lambda x: (
0 if (x.get("due_date") and x["due_date"] < today and x.get("status") not in ("done", "cancelled")) else
1 if (x.get("due_date") == today and x.get("status") not in ("done", "cancelled")) else
2 if (x.get("priority") == "high" and x.get("status") not in ("done", "cancelled")) else
3 if (x.get("status") == "in_progress") else 4
)):
if t.get("status") in ("done", "cancelled"):
continue
formatted = format_task(t, today)
if formatted not in seen_titles:
seen_titles.add(formatted)
focus_tasks.append(formatted)
if len(focus_tasks) >= 5:
break
# Calendar gap analysis — extract event times for LLM
event_time_blocks: list[str] = []
for e in internal_events:
try:
if e.get("all_day"):
continue
start = e.get("start_dt")
end = e.get("end_dt")
if not start:
continue
from datetime import datetime as _dt
if isinstance(start, str):
start = _dt.fromisoformat(start)
if isinstance(end, str):
end = _dt.fromisoformat(end)
s_local = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
s_str = s_local.strftime("%-I:%M %p")
if end:
e_local = end.astimezone(user_tz) if end.tzinfo else end.replace(tzinfo=timezone.utc).astimezone(user_tz)
e_str = e_local.strftime("%-I:%M %p")
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}{e_str}")
else:
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}?")
except Exception:
pass
return { return {
"date": today, "date": today,
"overdue_tasks": overdue, "overdue_tasks": overdue,
@@ -214,6 +360,80 @@ async def _gather_internal(user_id: int) -> dict:
"calendar_events": calendar_events, "calendar_events": calendar_events,
"active_projects": projects_summary, "active_projects": projects_summary,
"all_tasks_raw": all_tasks, "all_tasks_raw": all_tasks,
"focus_tasks": focus_tasks,
"event_time_blocks": event_time_blocks,
"weather_warnings": weather_warnings,
"recent_activity": recent_activity,
"stale_projects": stale_projects,
}
async def _gather_weekly_review(user_id: int) -> dict:
"""Collect 7-day activity summary for the weekly review."""
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.events import list_events as list_internal_events
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
now = datetime.now(user_tz)
week_ago = now - __import__('datetime').timedelta(days=7)
next_week = now + __import__('datetime').timedelta(days=7)
# Tasks completed this week
completed_tasks = []
overdue_tasks = []
try:
all_task_objs, _ = await list_notes(user_id, is_task=True, limit=200)
for t in all_task_objs:
if t.status == "done" and t.completed_at and t.completed_at >= week_ago:
completed_tasks.append(t.title or "Untitled")
elif t.due_date and t.due_date.isoformat() < now.date().isoformat() and t.status not in ("done", "cancelled"):
overdue_tasks.append(t.title or "Untitled")
except Exception:
logger.debug("Weekly review: failed to gather tasks", exc_info=True)
# Notes created this week
notes_created = 0
try:
all_notes, _ = await list_notes(user_id, is_task=False, limit=200)
notes_created = sum(1 for n in all_notes if n.created_at and n.created_at >= week_ago)
except Exception:
logger.debug("Weekly review: failed to count notes", exc_info=True)
# Project activity
active_projects = []
try:
projects = await list_projects(user_id)
active_projects = [p.title for p in projects if p.status == "active"][:5]
except Exception:
pass
# Events next week
upcoming_events = []
try:
next_events = await list_internal_events(
user_id=user_id,
date_from=now,
date_to=next_week,
)
for e in next_events[:8]:
upcoming_events.append(f"{e.get('title', 'Event')}{e.get('start_dt', '')[:10]}")
except Exception:
pass
return {
"completed_count": len(completed_tasks),
"completed_highlights": completed_tasks[:5],
"overdue_count": len(overdue_tasks),
"overdue_items": overdue_tasks[:5],
"notes_created": notes_created,
"active_projects": active_projects,
"upcoming_events": upcoming_events,
} }
@@ -259,23 +479,107 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_
return "" return ""
def _unified_system_prompt(profile_body: str) -> str: def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str:
return ( base = (
"You are a personal assistant delivering a daily briefing. " "You are a personal assistant. "
"Speak naturally and conversationally — as if talking to the user, not writing a report. " "Speak naturally and conversationally — as if talking to the user, not writing a report. "
"Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. " "Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. "
"Weave together what matters today: mention the weather in a sentence, note any calendar " "Be warm, concise, and human. "
"events or tasks due today, and briefly reference one or two noteworthy news stories. " "The user can reply to act on your suggestions via tool calls.\n\n"
"Only mention projects if a task from one is specifically due today. "
"Be warm, concise, and human — aim for 3 to 5 sentences. "
"Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n"
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
) )
if slot == "compilation":
base += (
"This is the MORNING BRIEFING — the full daily overview. "
"Weave together what matters today: mention the weather, note calendar events and tasks, "
"briefly reference one or two noteworthy news themes, and suggest a daily focus. "
"Be actionable: for overdue or due-today tasks, suggest a concrete next step. "
"Aim for 4 to 8 sentences.\n\n"
)
elif slot in ("morning", "midday"):
base += (
"This is a MIDDAY CHECK-IN — brief progress nudge, not a full briefing. "
"Focus on what's been done and what hasn't been touched yet today. "
"If weather conditions have changed, mention it briefly. "
"Keep it to 2-3 sentences. Don't repeat news or the full task list.\n\n"
)
elif slot == "afternoon":
base += (
"This is the AFTERNOON WRAP-UP — help the user close out the day. "
"Summarize what was accomplished today. Flag anything still overdue. "
"Ask if there's anything to capture before tomorrow — notes, thoughts, follow-ups. "
"Keep it to 2-4 sentences. Reflective and encouraging tone.\n\n"
)
elif slot == "weekly_review":
base += (
"This is the WEEKLY REVIEW — a reflective recap of the past 7 days. "
"Summarize accomplishments, flag lingering overdue items, note how many notes were created, "
"and preview the upcoming week's key events. "
"Be reflective and encouraging — celebrate progress, gently flag what's stuck. "
"Aim for 5 to 8 sentences. This wraps up a chapter.\n\n"
)
if profile_body:
base += f"User profile:\n{profile_body}\n"
return base
def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str: def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str:
lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""] lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""]
# Weekly review — separate data path
if slot == "weekly_review":
weekly = internal_data.get("weekly_review") or {}
lines.append(f"COMPLETED THIS WEEK: {weekly.get('completed_count', 0)} tasks")
if weekly.get("completed_highlights"):
lines.extend(f" - {t}" for t in weekly["completed_highlights"])
lines.append("")
if weekly.get("overdue_count", 0) > 0:
lines.append(f"STILL OVERDUE: {weekly['overdue_count']} task(s)")
lines.extend(f" - {t}" for t in weekly.get("overdue_items", []))
lines.append("")
lines.append(f"NOTES CREATED: {weekly.get('notes_created', 0)}")
lines.append("")
if weekly.get("active_projects"):
lines.append(f"ACTIVE PROJECTS: {', '.join(weekly['active_projects'])}")
lines.append("")
if weekly.get("upcoming_events"):
lines.append("UPCOMING WEEK:")
lines.extend(f" - {e}" for e in weekly["upcoming_events"])
lines.append("")
return "\n".join(lines)
# For check-in slots, build a slimmer prompt
if slot in ("morning", "midday"):
# Just tasks status + any weather changes
if internal_data.get("due_today"):
lines.append("TASKS DUE TODAY (note which are still untouched):")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if internal_data.get("overdue_tasks"):
lines.append(f"STILL OVERDUE: {len(internal_data['overdue_tasks'])} task(s)")
lines.append("")
if internal_data.get("calendar_events"):
lines.append("REMAINING EVENTS TODAY:")
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("")
return "\n".join(lines)
if slot == "afternoon":
if internal_data.get("due_today"):
lines.append("TASKS THAT WERE DUE TODAY:")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} remaining):")
lines.extend(f" - {t}" for t in overdue[:3])
lines.append("")
lines.append("Ask the user if there's anything to capture before tomorrow — a note, a thought, a follow-up task.")
return "\n".join(lines)
# ── Full compilation prompt below ──────────────────────────────────────────
# Weather (brief — card handles detail) # Weather (brief — card handles detail)
weather = external_data.get("weather") or [] weather = external_data.get("weather") or []
if weather: if weather:
@@ -298,32 +602,104 @@ def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, te
lines.extend(f" - {e}" for e in internal_data["calendar_events"]) lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("") lines.append("")
# Weather warnings for outdoor events
if internal_data.get("weather_warnings"):
lines.append("WEATHER ALERTS (mention these naturally when discussing affected events):")
lines.extend(f"{w}" for w in internal_data["weather_warnings"])
lines.append("")
# Tasks due today # Tasks due today
if internal_data.get("due_today"): if internal_data.get("due_today"):
lines.append("DUE TODAY:") lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):")
lines.extend(f" - {t}" for t in internal_data["due_today"]) lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("") lines.append("")
# Overdue tasks (brief mention only) # Overdue tasks
if internal_data.get("overdue_tasks"): if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"] overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):") lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}) — suggest rescheduling or breaking down:")
lines.extend(f" - {t}" for t in overdue[:3]) lines.extend(f" - {t}" for t in overdue[:5])
if len(overdue) > 3: if len(overdue) > 5:
lines.append(f" (and {len(overdue) - 3} more)") lines.append(f" (and {len(overdue) - 5} more)")
lines.append("") lines.append("")
# News highlights (top 3 with excerpts — right panel shows full list) # High priority in-progress
if internal_data.get("high_priority"):
lines.append("HIGH PRIORITY:")
lines.extend(f" - {t}" for t in internal_data["high_priority"])
lines.append("")
# Your Day — prioritized focus + calendar gaps (compilation slot only)
if internal_data.get("focus_tasks") and slot == "compilation":
lines.append("YOUR DAY — top tasks to focus on (mention these as a suggested plan):")
for i, t in enumerate(internal_data["focus_tasks"], 1):
lines.append(f" {i}. {t}")
if internal_data.get("event_time_blocks"):
lines.append("")
lines.append(" Scheduled time blocks:")
lines.extend(f" {b}" for b in internal_data["event_time_blocks"])
lines.append(" (Identify free blocks between events for focused work.)")
lines.append("")
# Project continuity — what was the user working on?
if internal_data.get("recent_activity") and slot == "compilation":
lines.append("YESTERDAY'S ACTIVITY (mention what the user was working on to help them pick up):")
lines.extend(f" - {a}" for a in internal_data["recent_activity"])
lines.append("")
if internal_data.get("stale_projects") and slot == "compilation":
lines.append("STALE PROJECTS (no activity in 7+ days — gently flag):")
lines.extend(f" - {p}" for p in internal_data["stale_projects"])
lines.append("")
# News — clustered by topic, ranked by preference score
rss = external_data.get("rss_items") or [] rss = external_data.get("rss_items") or []
topic_scores = external_data.get("topic_scores") or {}
if rss: if rss:
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):") # Group articles by their primary topic
for item in rss[:3]: clusters: dict[str, list[dict]] = {}
source = item.get("feed_title") or item.get("source") or "News" uncategorized: list[dict] = []
title = item.get("title", "") for item in rss:
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip() topics = item.get("topics") or []
lines.append(f" [{source}] {title}") if topics:
if excerpt: primary = topics[0]
lines.append(f" {excerpt}") clusters.setdefault(primary, []).append(item)
else:
uncategorized.append(item)
# Score each cluster by aggregate preference (positive = user likes this topic)
def cluster_score(topic: str, items: list[dict]) -> float:
pref = topic_scores.get(topic, 0.0)
return pref * 2.0 + len(items) # preference-weighted, size as tiebreak
# Filter out clusters where the user has a strongly negative preference
filtered_clusters = [
(topic, items) for topic, items in clusters.items()
if topic_scores.get(topic, 0.0) >= -2.0
]
# Sort by preference-weighted score
sorted_clusters = sorted(filtered_clusters, key=lambda x: cluster_score(x[0], x[1]), reverse=True)
lines.append("NEWS THEMES (synthesize 1-2 themes into your briefing; mention article count per theme):")
for i, (topic, items) in enumerate(sorted_clusters[:4]):
count = len(items)
titles = [it.get("title", "") for it in items[:3]]
sources = list({it.get("feed_title") or it.get("source") or "News" for it in items})
pref_indicator = ""
pref = topic_scores.get(topic, 0.0)
if pref >= 2.0:
pref_indicator = "" # user's preferred topic
lines.append(f" [{topic.title()}]{pref_indicator} ({count} article{'s' if count != 1 else ''} from {', '.join(sources[:2])})")
for t in titles:
lines.append(f"{t}")
# Include excerpt for preferred clusters or the top cluster
if pref >= 1.0 or i == 0:
excerpt = (items[0].get("content") or items[0].get("snippet") or "")[:400].strip()
if excerpt:
lines.append(f" > {excerpt}")
if uncategorized:
lines.append(f" [Other] ({len(uncategorized)} uncategorized article{'s' if len(uncategorized) != 1 else ''})")
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
@@ -389,6 +765,10 @@ async def run_compilation(
get_cached_weather_rows(user_id), get_cached_weather_rows(user_id),
) )
# Weekly review: gather 7-day summary data
if slot == "weekly_review":
internal_data["weekly_review"] = await _gather_weekly_review(user_id)
# Task change detection # Task change detection
all_tasks = internal_data.get("all_tasks_raw", []) all_tasks = internal_data.get("all_tasks_raw", [])
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks) changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
@@ -429,10 +809,11 @@ async def run_compilation(
external_data_filtered = { external_data_filtered = {
"rss_items": filtered_rss, "rss_items": filtered_rss,
"weather": [], "weather": [],
"topic_scores": topic_scores,
} }
briefing_text = await _llm_synthesise( briefing_text = await _llm_synthesise(
_unified_system_prompt(profile_context), _unified_system_prompt(profile_context, slot),
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit), _unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
model, model,
num_ctx=8192, num_ctx=8192,
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
import asyncio import asyncio
import logging import logging
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -36,6 +36,11 @@ SLOTS = [
("afternoon", 16, 0), ("afternoon", 16, 0),
] ]
# Weekly review runs Sunday at 6pm by default
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
WEEKLY_REVIEW_HOUR = 18
WEEKLY_REVIEW_MINUTE = 0
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
) )
# Weekly review job — runs once per week
weekly_jid = _job_id(user_id, "weekly_review")
weekly_on = enabled_slots.get("weekly_review", True)
if weekly_on:
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(
day_of_week=WEEKLY_REVIEW_DAY,
hour=WEEKLY_REVIEW_HOUR,
minute=WEEKLY_REVIEW_MINUTE,
timezone=tz,
),
args=[user_id, "weekly_review"],
id=weekly_jid,
replace_existing=True,
misfire_grace_time=7200,
)
elif _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz) logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
@@ -113,6 +138,9 @@ def _remove_user_jobs(user_id: int) -> None:
jid = _job_id(user_id, slot_name) jid = _job_id(user_id, slot_name)
if _scheduler.get_job(jid): if _scheduler.get_job(jid):
_scheduler.remove_job(jid) _scheduler.remove_job(jid)
weekly_jid = _job_id(user_id, "weekly_review")
if _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Removed briefing jobs for user %d", user_id) logger.info("Removed briefing jobs for user %d", user_id)
@@ -134,6 +162,35 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
# ── Job execution ───────────────────────────────────────────────────────────── # ── Job execution ─────────────────────────────────────────────────────────────
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.project import Project
from fabledassistant.models.note import Note
from fabledassistant.models import async_session as _session
paused: list[str] = []
threshold = datetime.now(timezone.utc) - timedelta(days=14)
try:
async with _session() as session:
projects = (await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)).scalars().all()
for p in projects:
latest = (await session.execute(
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
)).scalar()
if latest is None or latest < threshold:
p.status = "paused"
paused.append(p.title or "Untitled")
if paused:
await session.commit()
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
except Exception:
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
return paused
async def _run_slot_for_user(user_id: int, slot: str) -> None: async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user.""" """Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import ( from fabledassistant.services.briefing_conversations import (
@@ -164,6 +221,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation": if slot == "compilation":
# Auto-pause stale projects before compiling the briefing
await _auto_pause_stale_projects(user_id)
# Refresh external data first # Refresh external data first
try: try:
import json import json
+19 -13
View File
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str: def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template.""" """Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html> <html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head> <head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> <meta charset="utf-8">
<div style="padding:32px 16px;"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
</head>
<body style="margin:0;padding:0;background:#f5f3ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="padding:36px 16px 48px;">
<div style="max-width:520px;margin:0 auto;"> <div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Card -->
<div style="background:#ffffff;border:1px solid #ddd6fe;border-radius:14px;overflow:hidden;box-shadow:0 4px 24px rgba(109,40,217,0.08);">
<!-- Header --> <!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;"> <div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:6px;"> <div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span> {_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
</div> </div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p> <p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div> </div>
<!-- Body --> <!-- Body -->
<div style="padding:28px 24px;"> <div style="padding:32px 28px;color:#1e1b4b;">
{body} {body}
</div> </div>
<!-- Footer --> <!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;"> <div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p> <p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
</div> </div>
</div> </div>
@@ -169,7 +175,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
async def send_test_email(to: str) -> None: async def send_test_email(to: str) -> None:
"""Send a branded test email.""" """Send a branded test email."""
body = """ body = """
<p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p> <p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p> <p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
""" """
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body)) await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
+61 -16
View File
@@ -29,10 +29,15 @@ def _note_to_item(note: Note) -> dict:
item["relationship"] = meta.get("relationship", "") item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "") item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "") item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
elif note.entity_type == "place": elif note.entity_type == "place":
item["address"] = meta.get("address", "") item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "") item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "") item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
elif note.entity_type == "list": elif note.entity_type == "list":
# Parse markdown task list syntax into structured items # Parse markdown task list syntax into structured items
body = note.body or "" body = note.body or ""
@@ -117,34 +122,74 @@ async def _semantic_knowledge_search(
limit: int, limit: int,
offset: int, offset: int,
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
"""Semantic search over knowledge objects, with SQL filters applied post-rank.""" """Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
async with async_session() as session:
pattern = f"%{q}%"
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
base = base.order_by(
Note.title.ilike(pattern).desc(),
Note.updated_at.desc(),
).limit(limit * 2)
keyword_notes = list((await session.execute(base)).scalars().all())
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity
semantic_notes: list[Note] = []
try: try:
from fabledassistant.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
# Fetch a larger candidate set to allow for filtering
is_task_filter = True if note_type == "task" else (False if note_type else None) is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes( candidates = await semantic_search_notes(
user_id=user_id, user_id=user_id,
query=q, query=q,
limit=min(200, limit * 8), limit=min(200, limit * 4),
threshold=0.3, threshold=0.3,
is_task=is_task_filter, is_task=is_task_filter,
) )
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
semantic_notes.append(note)
except Exception: except Exception:
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
results = [] # 3. Merge — keyword matches first, then semantic (deduplicated)
for _score, note in candidates: seen_ids: set[int] = set()
if note_type == "task" and not note.is_task: merged: list[Note] = []
continue for note in keyword_notes:
elif note_type and note_type != "task" and note.entity_type != note_type: if note.id not in seen_ids:
continue seen_ids.add(note.id)
if tags and not all(t in (note.tags or []) for t in tags): merged.append(note)
continue for note in semantic_notes:
results.append(note) if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
total = len(results) total = len(merged)
page_items = results[offset: offset + limit] page_items = merged[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total return [_note_to_item(n) for n in page_items], total
+18
View File
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out return out
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from fabledassistant.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None: async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old.""" """Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio import asyncio
@@ -92,6 +108,7 @@ async def create_note(
await session.refresh(note) await session.refresh(note)
if project_id is not None: if project_id is not None:
await _maybe_reactivate_project(project_id)
await _maybe_trigger_project_summary(user_id, project_id) await _maybe_trigger_project_summary(user_id, project_id)
return note return note
@@ -284,6 +301,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
await create_version(user_id, note_id, old_body, old_title, old_tags) await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None: if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
await _maybe_trigger_project_summary(user_id, note.project_id) await _maybe_trigger_project_summary(user_id, note.project_id)
return note return note
@@ -103,7 +103,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
We received a request to reset your password. Click the button below to choose a new password. We received a request to reset your password. Click the button below to choose a new password.
</p> </p>
<div style="text-align:center;margin:24px 0;"> <div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;"> <a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password Reset Password
</a> </a>
</div> </div>
@@ -136,7 +136,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
Click the button below to create your account. Click the button below to create your account.
</p> </p>
<div style="text-align:center;margin:24px 0;"> <div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;"> <a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation Accept Invitation
</a> </a>
</div> </div>
+74
View File
@@ -184,6 +184,80 @@ async def geocode(query: str) -> tuple[float, float, str]:
return float(r["lat"]), float(r["lon"]), r.get("display_name", query) return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
"""Fetch current temperature + conditions + next 3 hours precipitation.
Lightweight call — no daily forecast, no caching needed.
Returns dict with: temperature, windspeed, weathercode, description,
and precip_next_3h (list of hourly precip probabilities).
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"current_weather": "true",
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
current = data.get("current_weather", {})
hourly = data.get("hourly", {})
hourly_times = hourly.get("time", [])
hourly_precip = hourly.get("precipitation_probability", [])
# Find the current hour index and get next 3 hours of precip
current_time = current.get("time", "")
precip_next_3h = []
try:
idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
precip_next_3h = hourly_precip[idx:idx + 3]
except (StopIteration, IndexError):
pass
code = current.get("weathercode", 0)
return {
"temperature": current.get("temperature"),
"windspeed": current.get("windspeed"),
"weathercode": code,
"description": describe_weathercode(code),
"time": current_time,
"precip_next_3h": precip_next_3h,
}
except Exception:
logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
return None
async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
"""Fetch today's hourly precipitation probabilities.
Returns a dict of ISO hour string → probability percentage, e.g.:
{"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
hourly = data.get("hourly", {})
times = hourly.get("time", [])
probs = hourly.get("precipitation_probability", [])
return {t: p for t, p in zip(times, probs) if p is not None}
except Exception:
logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
return {}
async def _fetch_open_meteo(lat: float, lon: float) -> dict: async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data.""" """Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client: