Compare commits

...

8 Commits

Author SHA1 Message Date
bvandeusen 190664366d Merge pull request 'dev → main' (#12) from dev into main
dev → main
2026-03-26 22:08:03 +00:00
bvandeusen 08d738ddfb feat: silent background polling for dashboard and briefing views
HomeView: setInterval every 90s refreshes events, orphan tasks/notes,
and hero next-up task. Never touches loading ref, so the page content
stays stable — only the data silently swaps when the fetch completes.
Skips when document.hidden or initial load is still in progress.

BriefingView: setInterval every 60s refetches today's messages, but
only when viewing today's conversation and not currently streaming.
Compares message count and last message content before updating to
avoid unnecessary re-renders.

Both timers are cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:43:21 -04:00
bvandeusen a63e498067 fix: weather card spacing and sizing consistency
- Increase temp size to 2rem and add explicit font-size to condition
- Uniform 0.5rem spacing below current temp (was 0.35rem)
- Today row font-size explicit at 0.85rem
- Forecast gap 0.75rem (was 0.5rem), min-width 4.5rem (was 3.5rem)
- Padding-top on forecast strip 0.75rem to match overall rhythm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:37:27 -04:00
bvandeusen 48d1d9e64f fix: remove npm self-update in Docker build; use npm ci
npm install -g npm@latest corrupts npm's own module tree inside Alpine,
breaking subsequent installs. Use npm ci instead (faster, deterministic).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:19:01 -04:00
bvandeusen 538b67e57d docs(fable-mcp): add server instructions and detailed tool docstrings
Adds a comprehensive instructions block to the FastMCP server covering
the data model hierarchy, valid enum values, tag format, integer-or-none
conventions, when to use fable_send_message vs direct CRUD tools, and
admin key requirements.

All tool docstrings expanded with full argument descriptions, valid
values, and return shape notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:03:31 -04:00
bvandeusen 383a4430f1 fix: convert calendar event times to user timezone in briefing
The briefing was formatting event start_dt directly in UTC instead of
converting to the user's local timezone. Also, the day_start/day_end
query window was naive (UTC), so events at the edges of the user's day
could be missed or included incorrectly.

Now reads user_timezone setting, uses it for today's date boundary and
for converting each event's start_dt before formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 06:34:22 -04:00
bvandeusen 4aacd093e5 Merge pull request 'fix: decode HTML entities in wikilinks before re-escaping' (#11) from dev into main 2026-03-26 02:50:00 +00:00
bvandeusen aab478359b fix: decode HTML entities in wikilinks before re-escaping
marked encodes " to &quot; in text nodes, causing linkifyWikilinks to
double-escape it (& → &amp;) so the visible link text showed &quot;
instead of the actual character.

Decode marked's entities on the matched title/label before running
escapeHtmlAttr so the output is correct in both the href attribute
and the visible link text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:40:25 -04:00
7 changed files with 353 additions and 49 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-alpine AS build-frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install -g npm@latest --quiet && npm install
RUN npm ci --quiet
COPY frontend/ .
RUN npm run build
+255 -34
View File
@@ -9,7 +9,76 @@ from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, ad
load_dotenv()
mcp = FastMCP("fable")
_INSTRUCTIONS = """
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
## Data model
The hierarchy is: Project → Milestone → Task/Note.
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
Do not use note tools to manipulate tasks or vice versa.
- **Projects** group related work. A project has a title, description, goal, status, and an
auto-generated summary used for semantic search. Status values: `active`, `archived`.
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
- Status values: `todo`, `in_progress`, `done`, `cancelled`
- Priority values: `low`, `normal`, `high`
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
## Tags
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
leaves existing tags unchanged on updates.
## Integer-or-none fields
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
## Search
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
similarity with id, title, a body snippet, and tags.
## Chat / LLM delegation
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
handles its own tool use, RAG context injection, and conversation history internally.
Use `fable_send_message` when:
- The request is conversational or requires Fable's internal reasoning across many records
- You want Fable's RAG to surface relevant notes automatically
Use the direct CRUD tools when:
- You know exactly what to create/read/update/delete
- You need structured data back (IDs, field values) for further processing
- You are populating Fable programmatically from another system
## Task logs
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
its main body. Suitable for recording work sessions, decisions, or status updates over time.
## RSS / Briefing
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
## Admin logs
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
"""
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
# ---------------------------------------------------------------------------
@@ -24,7 +93,13 @@ async def fable_list_notes(
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes stored in Fable. Optionally filter by tag or search text."""
"""List notes (non-task documents) stored in Fable.
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
against title and body. Results are ordered by last-updated descending.
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
"""
async with FableClient() as client:
return await notes.list_notes(
client,
@@ -37,7 +112,10 @@ async def fable_list_notes(
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID."""
"""Fetch the full content of a single Fable note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
"""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@@ -49,7 +127,16 @@ async def fable_create_note(
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable."""
"""Create a new note in Fable.
Args:
title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
Returns the created note object including its assigned id.
"""
async with FableClient() as client:
return await notes.create_note(
client,
@@ -68,7 +155,15 @@ async def fable_update_note(
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only provided fields are changed."""
"""Update an existing Fable note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association (0 = remove from project). Omit to leave unchanged.
"""
async with FableClient() as client:
return await notes.update_note(
client,
@@ -82,7 +177,7 @@ async def fable_update_note(
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Delete a Fable note by ID."""
"""Permanently delete a Fable note by ID. This cannot be undone."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
@@ -100,7 +195,14 @@ async def fable_list_tasks(
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable. Filter by status (todo/in_progress/done) or project."""
"""List tasks in Fable.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
Results are ordered by last-updated descending.
"""
async with FableClient() as client:
return await tasks.list_tasks(
client,
@@ -113,7 +215,11 @@ async def fable_list_tasks(
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID, including parent task title."""
"""Fetch a single Fable task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at.
"""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@@ -129,7 +235,20 @@ async def fable_create_task(
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable."""
"""Create a new task in Fable.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, normal, high. Omit for no priority.
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
Returns the created task object including its assigned id.
"""
async with FableClient() as client:
return await tasks.create_task(
client,
@@ -154,7 +273,17 @@ async def fable_update_task(
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only provided fields are changed."""
"""Update an existing Fable task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: low, normal, high.
project_id: New project (0 = remove from project). Omit to leave unchanged.
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
"""
async with FableClient() as client:
return await tasks.update_task(
client,
@@ -170,7 +299,12 @@ async def fable_update_task(
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
"""Append a progress log entry to a Fable task."""
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
overwriting the task's main body. Each entry is stored separately and shown
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
@@ -182,14 +316,22 @@ async def fable_add_task_log(task_id: int, body: str) -> dict:
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user."""
"""List all Fable projects for the current user.
Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project.
"""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including milestone summary."""
"""Fetch a Fable project by ID, including its milestone summary.
Returns full project fields plus a milestone_summary list with each milestone's
id, title, status, and task counts.
"""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@@ -202,7 +344,17 @@ async def fable_create_project(
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable."""
"""Create a new project in Fable.
Args:
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: active (default) or archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
Returns the created project object including its assigned id.
"""
async with FableClient() as client:
return await projects.create_project(
client,
@@ -223,7 +375,16 @@ async def fable_update_project(
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project."""
"""Update an existing Fable project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — active or archived.
color: New hex colour, or omit to leave unchanged.
"""
async with FableClient() as client:
return await projects.update_project(
client,
@@ -243,7 +404,10 @@ async def fable_update_project(
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project."""
"""List milestones for a Fable project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, and task counts.
"""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@@ -255,7 +419,16 @@ async def fable_create_milestone(
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project."""
"""Create a milestone within a Fable project.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
status: active (default) or done.
Returns the created milestone including its assigned id.
"""
async with FableClient() as client:
return await milestones.create_milestone(
client,
@@ -275,7 +448,16 @@ async def fable_update_milestone(
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone."""
"""Update a Fable milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to.
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
async with FableClient() as client:
return await milestones.update_milestone(
client,
@@ -299,10 +481,17 @@ async def fable_search(
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks.
"""Semantic search over Fable notes and tasks using embedding similarity.
content_type: "note", "task", or "all" (default).
Returns results ranked by similarity with id, title, body snippet, tags.
Finds content by meaning rather than exact keywords. Use this to discover
relevant records when you don't know the exact title or tags.
Args:
q: Natural-language query string.
content_type: "note", "task", or "all" (default).
limit: Maximum number of results (default 10).
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
@@ -315,7 +504,11 @@ async def fable_search(
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List MCP chat conversations stored in Fable."""
"""List chat conversations stored in Fable, ordered by last activity.
Returns id, title, message_count, created_at, updated_at for each conversation.
Use the id with fable_send_message to continue a specific conversation.
"""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@@ -326,11 +519,28 @@ async def fable_send_message(
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a message to Fable's LLM and receive the full response.
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
Fable handles tool use, RAG, and history internally.
Pass conversation_id to continue an existing conversation.
Returns conversation_id, response text, and any tool_call events.
Fable handles tool use, RAG context injection, and conversation history internally.
The LLM can create/update notes and tasks, search, manage projects, and more — all
driven by natural language without you needing to call individual tools.
Use this when:
- The request is conversational or exploratory
- You want Fable's RAG to automatically surface relevant notes as context
- The task is complex enough to benefit from Fable's internal reasoning
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
structured data back or are performing bulk/programmatic operations.
Args:
message: The user message to send.
conversation_id: Continue an existing conversation by passing its id.
Omit to start a new conversation.
think: Enable extended reasoning mode for complex multi-step requests.
Returns conversation_id (for follow-up messages), the assistant response text,
and a list of any tool_call events that fired during generation.
"""
async with FableClient() as client:
return await chat.send_message(
@@ -352,10 +562,15 @@ async def fable_get_app_logs(
limit: int = 20,
search: str = "",
) -> dict:
"""Fetch Fable application logs. Requires an admin API key.
"""Fetch Fable application logs. Requires an admin-scoped API key.
category: "error" (default) | "audit" | "usage"
search: optional keyword filter applied to action, endpoint, username, and details
Args:
category: Log category — "error" (default), "audit", or "usage".
limit: Maximum number of log entries to return.
search: Optional keyword filter matched against action, endpoint, username, details.
Returns a list of log entries ordered by most recent first.
Regular user API keys will receive a 403 — only admin keys are accepted.
"""
async with FableClient() as client:
return await admin.get_app_logs(
@@ -373,7 +588,11 @@ async def fable_get_app_logs(
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS feeds configured in Fable for the current user."""
"""List all RSS/Atom feeds configured in Fable for the current user.
Returns id, title, url, category, and last_fetched_at for each feed.
These feeds are summarised in the user's daily briefing.
"""
async with FableClient() as client:
return await briefing.list_rss_feeds(client)
@@ -384,12 +603,14 @@ async def fable_add_rss_feed(
title: str = "",
category: str = "",
) -> dict:
"""Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata.
"""Add an RSS or Atom feed to Fable's daily briefing.
Args:
url: The RSS/Atom feed URL.
title: Optional display name override.
category: Optional category label (e.g. 'news', 'tech').
url: The RSS/Atom feed URL (required).
title: Optional display name. If omitted, auto-populated from feed metadata.
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
Returns the created feed object including its assigned id.
"""
async with FableClient() as client:
return await briefing.add_rss_feed(
+9 -7
View File
@@ -107,34 +107,36 @@ const fetchedAtLabel = computed(() => {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.35rem;
margin-bottom: 0.5rem;
}
.weather-temp {
font-size: 1.8rem;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.weather-condition {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.weather-today {
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.weather-delta {
color: var(--color-text-muted);
font-size: 0.85rem;
font-size: 0.82rem;
}
.weather-forecast {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
overflow-x: auto;
padding-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
@@ -142,8 +144,8 @@ const fetchedAtLabel = computed(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
min-width: 3.5rem;
gap: 0.25rem;
min-width: 4.5rem;
font-size: 0.8rem;
}
+13 -2
View File
@@ -5,6 +5,17 @@ function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Decode HTML entities that marked introduces before we re-escape for our own output.
// Order matters: &amp; must be last to avoid double-decoding.
function decodeHtmlEntities(s: string): string {
return s
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
export function extractTags(body: string): string[] {
const cleaned = body.replace(CODE_FENCE_RE, "");
const tags = new Set<string>();
@@ -39,8 +50,8 @@ export function linkifyWikilinks(html: string): string {
.map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => {
const trimmed = title.trim();
const label = display || trimmed;
const trimmed = decodeHtmlEntities(title.trim());
const label = display ? decodeHtmlEntities(display) : trimmed;
const encoded = encodeURIComponent(trimmed);
return `<a class="wikilink" data-title="${escapeHtmlAttr(trimmed)}" href="/notes/by-title?title=${encoded}">${escapeHtmlAttr(label)}</a>`;
});
+22
View File
@@ -189,9 +189,31 @@ function toMsg(m: BriefingMessage): Message {
}
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
let _refreshTimer: ReturnType<typeof setInterval> | null = null
async function _backgroundRefreshMessages() {
if (document.hidden || chatStore.streaming || !isToday.value || !todayConvId.value) return
try {
const today = await getBriefingToday()
if (!today) return
const fresh = today.messages
const last = fresh[fresh.length - 1]
const cur = messages.value[messages.value.length - 1]
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
messages.value = fresh
}
} catch { /* silent — don't disturb the UI on network hiccup */ }
}
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
_refreshTimer = setInterval(_backgroundRefreshMessages, 60_000)
})
onUnmounted(() => {
if (_refreshTimer !== null) clearInterval(_refreshTimer)
})
</script>
+40
View File
@@ -82,6 +82,43 @@ function milestoneColor(index: number): string {
return palette[index % palette.length];
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
// Runs on a timer while the page is visible. Never touches `loading` so
// existing content stays on screen while the fetch is in flight.
let _refreshTimer: ReturnType<typeof setInterval> | null = null
function _dateRange() {
const today = new Date()
const nextWeek = new Date(today)
nextWeek.setDate(today.getDate() + 7)
return {
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
}
}
function _backgroundRefresh() {
if (document.hidden || loading.value) return
const { todayStr, nextWeekStr } = _dateRange()
Promise.allSettled([
listEvents(todayStr, nextWeekStr),
apiGet<TaskListResponse>('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'),
apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'),
]).then(([eventsRes, tasksRes, notesRes]) => {
if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value
if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks
if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes
})
if (heroProject.value) {
apiGet<TaskListResponse>(
`/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1`
)
.then((r) => { heroNextUp.value = r.tasks[0] ?? null })
.catch(() => {})
}
}
// ─── Data loading ─────────────────────────────────────────────────────────────
onMounted(async () => {
@@ -135,6 +172,8 @@ onMounted(async () => {
// Focus chat input after data loads
chatInputRef.value?.focus();
loadProjects();
_refreshTimer = setInterval(_backgroundRefresh, 90_000)
});
async function loadProjects() {
@@ -200,6 +239,7 @@ onMounted(() => {
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
if (_refreshTimer !== null) clearInterval(_refreshTimer)
});
const chatStore = useChatStore();
@@ -8,6 +8,7 @@ import asyncio
import hashlib
import logging
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx
@@ -129,7 +130,13 @@ async def _gather_internal(user_id: int) -> dict:
from fabledassistant.services.projects import list_projects
from fabledassistant.services.caldav import is_caldav_configured, list_events
today = date.today().isoformat()
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
today = datetime.now(user_tz).date().isoformat()
# Tasks: overdue, due today, high priority in-progress
all_tasks: list[dict] = []
@@ -166,9 +173,9 @@ async def _gather_internal(user_id: int) -> dict:
calendar_events: list[str] = []
try:
from fabledassistant.services.events import list_events as list_internal_events
today_date = date.today()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59)
today_date = datetime.now(user_tz).date()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0, tzinfo=user_tz)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59, tzinfo=user_tz)
internal_events = await list_internal_events(
user_id=user_id, date_from=day_start, date_to=day_end
)
@@ -176,7 +183,8 @@ async def _gather_internal(user_id: int) -> dict:
if e.all_day:
time_str = "all day"
elif e.start_dt:
time_str = e.start_dt.strftime("%-I:%M %p")
local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz)
time_str = local_dt.strftime("%-I:%M %p")
else:
time_str = "unknown time"
calendar_events.append(f"{e.title} at {time_str}")