Compare commits
23
Commits
8b3bd4804e
...
v26.06.03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837489e4f2 | ||
|
|
9a0d5f3109 | ||
|
|
5a930319ba | ||
|
|
266af7870d | ||
|
|
f446573c3d | ||
|
|
82d6812c7f | ||
|
|
8c9ca45479 | ||
|
|
e023c21aa1 | ||
|
|
e3d7007417 | ||
|
|
65c85bab15 | ||
|
|
7ef7d10b24 | ||
|
|
6a3619555d | ||
|
|
0e980ee4b0 | ||
|
|
b5870d4694 | ||
|
|
c810d63bee | ||
|
|
a3a056d6fd | ||
|
|
2414437061 | ||
|
|
76dc75a03b | ||
|
|
c8765959ea | ||
|
|
f85b92a885 | ||
|
|
b81c4aa600 | ||
|
|
404698521f | ||
|
|
88b351a96e |
+28
-21
@@ -1,12 +1,19 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Push to main: typecheck + lint + test + build :<sha> (no moving tag)
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a production image. Re-running CI on the merge commit just burns
|
||||
# runner time without changing the outcome.
|
||||
# Both dev and main are gated AND built. dev pushes move the :dev tag; main
|
||||
# pushes publish only the immutable :<sha> image — no :main tag, because
|
||||
# :latest (release-only) is the single production pointer and a :main alias
|
||||
# would just duplicate it. Running CI on the main merge commit is intentional:
|
||||
# main is validated and its :<sha> image is the rollback point. The v* release
|
||||
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
|
||||
#
|
||||
# Successive pushes to the SAME ref supersede each other (see concurrency
|
||||
# below), so rapid pushes don't stack identical work; dev and main runs are
|
||||
# independent refs and never cancel one another.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
@@ -16,11 +23,8 @@
|
||||
# gating on branch push is already enough.
|
||||
#
|
||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
||||
# still trigger the workflow, producing redundant runs on the same SHA
|
||||
# that was already gated on dev. Every job therefore repeats the ref
|
||||
# check so main pushes trigger the workflow but every job skips
|
||||
# immediately (no runner time, no duplicate work).
|
||||
# honor `on.push.branches` as a filter, so every job repeats the ref check
|
||||
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
@@ -29,7 +33,7 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
@@ -67,8 +71,8 @@ env:
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
# Gate dev, main, and v* tags; reject any other ref (see header note).
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -92,7 +96,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -106,7 +110,7 @@ jobs:
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -138,11 +142,9 @@ jobs:
|
||||
build:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
|
||||
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -168,6 +170,11 @@ jobs:
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/heads/main)
|
||||
# main publishes only the immutable :<sha> image (set above) —
|
||||
# no :main tag; :latest (release-only) is the production pointer.
|
||||
BUILD_VERSION="main"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
|
||||
@@ -45,7 +45,8 @@ router.afterEach(() => {
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<div class="nav-center">
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
@@ -90,7 +91,8 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
|
||||
@@ -5,10 +5,15 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// Knowledge is the landing page in the MCP-first architecture
|
||||
// (chat / journal / workspace surfaces have been removed).
|
||||
// The dashboard ("what to work on") is the landing page; Knowledge
|
||||
// remains as the exhaustive "Browse" surface.
|
||||
path: "/",
|
||||
redirect: "/knowledge",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "dashboard",
|
||||
component: () => import("@/views/DashboardView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/knowledge",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string }
|
||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||
interface ActiveProject {
|
||||
id: number; title: string; color: string | null; last_activity: string;
|
||||
open_count: number; progress_pct: number;
|
||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||
}
|
||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||
interface DashboardData {
|
||||
active_projects: ActiveProject[];
|
||||
recently_completed: DoneItem[];
|
||||
upcoming_events: UpcomingEvent[];
|
||||
week_stats: WeekStats;
|
||||
}
|
||||
|
||||
const data = ref<DashboardData | null>(null);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||
} catch {
|
||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function fmtEvent(e: UpcomingEvent): string {
|
||||
if (!e.start_dt) return "";
|
||||
const d = new Date(e.start_dt);
|
||||
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
||||
if (e.all_day) return day;
|
||||
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dash-root">
|
||||
<header class="dash-head">
|
||||
<h1>Dashboard</h1>
|
||||
<p class="dash-sub">What to work on, across your most-active projects.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="dash-empty">Loading…</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Done recently -->
|
||||
<section v-if="data.recently_completed.length" class="done-strip">
|
||||
<span class="dash-label">✓ Done recently</span>
|
||||
<router-link
|
||||
v-for="d in data.recently_completed"
|
||||
:key="d.id"
|
||||
:to="`/tasks/${d.id}`"
|
||||
class="done-chip"
|
||||
>
|
||||
{{ d.title }}
|
||||
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<div class="dash-cols">
|
||||
<!-- Active now -->
|
||||
<main class="dash-main">
|
||||
<div class="dash-label">Active now</div>
|
||||
|
||||
<div v-if="!data.active_projects.length" class="dash-empty card">
|
||||
No active projects yet — <router-link to="/projects">create one</router-link>.
|
||||
</div>
|
||||
|
||||
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
|
||||
<header class="proj-head">
|
||||
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
|
||||
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
|
||||
</header>
|
||||
<div class="bar"><div class="bar-fill" :style="{ width: p.progress_pct + '%' }" /></div>
|
||||
|
||||
<div v-for="m in p.milestones" :key="m.id" class="ms-block">
|
||||
<div class="ms-head">
|
||||
<span class="ms-title">{{ m.title }}</span>
|
||||
<span class="ms-pct">{{ m.progress_pct }}%</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="t in m.open_tasks"
|
||||
:key="t.id"
|
||||
:to="`/tasks/${t.id}`"
|
||||
class="task-row"
|
||||
:class="{ 'task-inprogress': t.status === 'in_progress' }"
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="p.no_milestone.length" class="ms-block">
|
||||
<div class="ms-head"><span class="ms-title ms-none">No milestone</span></div>
|
||||
<router-link
|
||||
v-for="t in p.no_milestone"
|
||||
:key="t.id"
|
||||
:to="`/tasks/${t.id}`"
|
||||
class="task-row"
|
||||
:class="{ 'task-inprogress': t.status === 'in_progress' }"
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<router-link :to="`/projects/${p.id}`" class="proj-more">+ more in {{ p.title }} →</router-link>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<!-- Right rail -->
|
||||
<aside class="dash-rail">
|
||||
<div class="dash-label">Upcoming · 7 days</div>
|
||||
<div class="rail-card">
|
||||
<template v-if="data.upcoming_events.length">
|
||||
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
||||
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
||||
<span class="evt-title">{{ e.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="rail-empty">Nothing scheduled.</p>
|
||||
</div>
|
||||
|
||||
<div class="dash-label">This week</div>
|
||||
<div class="rail-card stats">
|
||||
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
||||
<span>○ {{ data.week_stats.open_total }} open</span>
|
||||
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
||||
</div>
|
||||
|
||||
<div class="quick-add">
|
||||
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
||||
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
||||
<router-link to="/notes/new?type=process" class="qa-btn">+ Process</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||
|
||||
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
|
||||
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.done-chip:hover { border-color: var(--color-primary); }
|
||||
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
|
||||
|
||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||
.dash-main { flex: 1.7; min-width: 0; }
|
||||
.dash-rail { flex: 1; min-width: 240px; }
|
||||
|
||||
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
||||
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
|
||||
.proj-title:hover { color: var(--color-primary); }
|
||||
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
|
||||
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
||||
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
|
||||
|
||||
.ms-block { margin-top: 0.7rem; }
|
||||
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
|
||||
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
|
||||
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
|
||||
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
|
||||
|
||||
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||
.task-row:hover { background: var(--color-hover); }
|
||||
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
|
||||
.task-mark { color: var(--color-muted); }
|
||||
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
|
||||
.pri-high { color: #c0556b; border-color: #c0556b66; }
|
||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||
|
||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
||||
.evt-row:last-child { border-bottom: none; }
|
||||
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
||||
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ from fabledassistant.routes.profile import profile_bp
|
||||
from fabledassistant.routes.knowledge import knowledge_bp
|
||||
from fabledassistant.routes.rulebooks import rulebooks_bp
|
||||
from fabledassistant.routes.trash import trash_bp
|
||||
from fabledassistant.routes.dashboard import dashboard_bp
|
||||
from fabledassistant.mcp import mount_mcp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
@@ -87,6 +88,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(knowledge_bp)
|
||||
app.register_blueprint(rulebooks_bp)
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
|
||||
@@ -36,7 +36,20 @@ Mechanics:
|
||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||
unchanged on updates.
|
||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||
"not set".
|
||||
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
|
||||
removes the task from its milestone); 0 leaves it unchanged.
|
||||
|
||||
Keep task state honest — this is what makes the project a trustworthy record:
|
||||
- When you begin working a task, set it to in_progress (update_task
|
||||
status=in_progress).
|
||||
- Log progress as you go with add_task_log — at meaningful steps, not saved up
|
||||
for the end.
|
||||
- The moment a task's work is complete, set it done. Never leave finished work
|
||||
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
|
||||
left to do.
|
||||
- At a significant landing (a merge, a shipped feature, a finished plan), write
|
||||
a short dated dev-log note on the project (create_note) summarizing what
|
||||
landed, and mark the plan/task done.
|
||||
|
||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||
an actionable statement plus optional Why and How-to-apply context. At the
|
||||
@@ -56,6 +69,19 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||
in a project, an existing rulebook+topic, or a new rulebook.
|
||||
|
||||
That boundary cuts the other way too. Because rules are pull-only, a fresh
|
||||
session won't reach for them unless its always-loaded context says to. So
|
||||
when the current project subscribes to a rulebook (enter_project /
|
||||
get_project reports subscribed_rulebooks), make sure the host's persistent
|
||||
memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at
|
||||
startup — carries a short pointer: that the engineering and workflow rules
|
||||
for this work live in Scribe and must be loaded via list_always_on_rules (or
|
||||
enter_project when a project is in scope), plus a one-line note of what the
|
||||
current project is and what is in flight. Add or refresh that pointer when
|
||||
it's missing or stale; never copy the rules' content into memory — the
|
||||
pointer plus project context is the whole job. This is what lets the next
|
||||
session reach for Scribe instead of trusting a stale local copy.
|
||||
|
||||
When you are working on a specific project, call enter_project(project_id)
|
||||
ONCE at session start (or whenever the active project changes). It returns the
|
||||
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||
@@ -63,6 +89,17 @@ summary, open tasks, and recent notes — everything you need to know the lay of
|
||||
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||
search separately when enter_project already composes them.
|
||||
|
||||
Don't wait to be told which project you're in. At the start of a session that
|
||||
touches Scribe — or the moment work clearly belongs to a project but none is in
|
||||
scope — bootstrap project context proactively: search for a related existing
|
||||
project (search / list_projects, matching on the work's subject, the repo or
|
||||
directory name, and recent activity). If you find a confident match, propose it
|
||||
and call enter_project once the operator confirms. If nothing matches, offer to
|
||||
create a project, confirming its name and goal first. Always confirm before
|
||||
adopting or creating — never do either silently, and never guess a project into
|
||||
existence. Once a project is in scope, the enter_project handshake and the
|
||||
host-memory pointer step above both apply.
|
||||
|
||||
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||
|
||||
@@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp):
|
||||
what makes a Note a Task)
|
||||
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
|
||||
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
|
||||
"leave unchanged" on update
|
||||
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -143,10 +143,14 @@ async def update_task(
|
||||
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.
|
||||
status: New status — one of: todo, in_progress, done, cancelled. Drive
|
||||
the lifecycle: set in_progress when you start, done when complete —
|
||||
don't leave finished work at todo.
|
||||
priority: New priority — one of: none, low, medium, high.
|
||||
project_id: New project. Omit (0) to leave unchanged.
|
||||
milestone_id: New milestone. Omit (0) to leave unchanged.
|
||||
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
|
||||
its project; also clears the milestone), positive = set.
|
||||
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
|
||||
from its milestone), positive = set.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -158,9 +162,15 @@ async def update_task(
|
||||
fields["status"] = status
|
||||
if priority:
|
||||
fields["priority"] = priority
|
||||
if project_id:
|
||||
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
|
||||
if project_id == -1:
|
||||
fields["project_id"] = None
|
||||
fields["milestone_id"] = None # a milestone can't outlive its project
|
||||
elif project_id:
|
||||
fields["project_id"] = project_id
|
||||
if milestone_id:
|
||||
if milestone_id == -1:
|
||||
fields["milestone_id"] = None
|
||||
elif milestone_id:
|
||||
fields["milestone_id"] = milestone_id
|
||||
note = await notes_svc.update_note(uid, task_id, **fields)
|
||||
if note is None:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Dashboard REST endpoint — the aggregated landing payload."""
|
||||
from quart import Blueprint, g, jsonify
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.services.dashboard import build_dashboard
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
|
||||
|
||||
|
||||
@dashboard_bp.get("")
|
||||
@login_required
|
||||
async def get_dashboard():
|
||||
return jsonify(await build_dashboard(g.user.id))
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Dashboard aggregation — assembles the /dashboard landing payload.
|
||||
|
||||
One call: most-recently-active projects (each -> active milestones -> open
|
||||
tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped,
|
||||
trashed rows excluded. Each section is independent — a failure returns its
|
||||
empty value rather than blanking the page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.services import milestones as milestones_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
N_PROJECTS = 3 # most-recently-active projects shown
|
||||
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
|
||||
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
|
||||
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
|
||||
_OPEN = ["todo", "in_progress"]
|
||||
|
||||
|
||||
def _open_order():
|
||||
"""in-progress first -> priority high..none -> most-recently-updated."""
|
||||
status_rank = case((Note.status == "in_progress", 0), else_=1)
|
||||
priority_rank = case(
|
||||
(Note.priority == "high", 0), (Note.priority == "medium", 1),
|
||||
(Note.priority == "low", 2), else_=3,
|
||||
)
|
||||
return status_rank, priority_rank, Note.updated_at.desc()
|
||||
|
||||
|
||||
def _task_row(n: Note) -> dict:
|
||||
return {"id": n.id, "title": n.title, "status": n.status,
|
||||
"priority": n.priority or "none"}
|
||||
|
||||
|
||||
async def _safe(coro, empty):
|
||||
try:
|
||||
return await coro
|
||||
except Exception:
|
||||
logger.warning("dashboard section failed", exc_info=True)
|
||||
return empty
|
||||
|
||||
|
||||
async def build_dashboard(user_id: int) -> dict:
|
||||
return {
|
||||
"active_projects": await _safe(_active_projects(user_id), []),
|
||||
"recently_completed": await _safe(_recently_completed(user_id), []),
|
||||
"upcoming_events": await _safe(_upcoming_events(user_id), []),
|
||||
"week_stats": await _safe(_week_stats(user_id), {}),
|
||||
}
|
||||
|
||||
|
||||
async def _active_projects(user_id: int) -> list[dict]:
|
||||
so, po, ro = _open_order()
|
||||
async with async_session() as session:
|
||||
recency = (
|
||||
select(Note.project_id, func.max(Note.updated_at).label("last"))
|
||||
.where(Note.user_id == user_id, Note.deleted_at.is_(None),
|
||||
Note.project_id.isnot(None))
|
||||
.group_by(Note.project_id).subquery()
|
||||
)
|
||||
prows = (await session.execute(
|
||||
select(Project, recency.c.last)
|
||||
.outerjoin(recency, Project.id == recency.c.project_id)
|
||||
.where(Project.user_id == user_id, Project.status == "active",
|
||||
Project.deleted_at.is_(None))
|
||||
.order_by(func.coalesce(recency.c.last, Project.updated_at).desc())
|
||||
.limit(N_PROJECTS)
|
||||
)).all()
|
||||
|
||||
out = []
|
||||
for project, last in prows:
|
||||
counts = (await session.execute(
|
||||
select(
|
||||
func.count(Note.id).filter(Note.status.in_(_OPEN)),
|
||||
func.count(Note.id).filter(Note.status == "done"),
|
||||
func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])),
|
||||
).where(Note.user_id == user_id, Note.project_id == project.id,
|
||||
Note.deleted_at.is_(None))
|
||||
)).one()
|
||||
open_count, done_count, resolved_total = counts
|
||||
progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0
|
||||
|
||||
mrows = (await session.execute(
|
||||
select(Milestone).where(
|
||||
Milestone.user_id == user_id, Milestone.project_id == project.id,
|
||||
Milestone.status == "active", Milestone.deleted_at.is_(None))
|
||||
.order_by(Milestone.order_index.asc())
|
||||
)).scalars().all()
|
||||
milestones = []
|
||||
for m in mrows:
|
||||
tasks = (await session.execute(
|
||||
select(Note).where(
|
||||
Note.user_id == user_id, Note.milestone_id == m.id,
|
||||
Note.status.in_(_OPEN), Note.deleted_at.is_(None))
|
||||
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
|
||||
)).scalars().all()
|
||||
if not tasks:
|
||||
continue
|
||||
prog = await milestones_svc.get_milestone_progress(m.id)
|
||||
milestones.append({
|
||||
"id": m.id, "title": m.title, "progress_pct": prog["pct"],
|
||||
"open_tasks": [_task_row(t) for t in tasks],
|
||||
})
|
||||
|
||||
no_ms = (await session.execute(
|
||||
select(Note).where(
|
||||
Note.user_id == user_id, Note.project_id == project.id,
|
||||
Note.milestone_id.is_(None), Note.status.in_(_OPEN),
|
||||
Note.deleted_at.is_(None))
|
||||
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
|
||||
)).scalars().all()
|
||||
|
||||
out.append({
|
||||
"id": project.id, "title": project.title, "color": project.color,
|
||||
"last_activity": (last or project.updated_at).isoformat(),
|
||||
"open_count": open_count, "progress_pct": progress_pct,
|
||||
"milestones": milestones,
|
||||
"no_milestone": [_task_row(t) for t in no_ms],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def _recently_completed(user_id: int) -> list[dict]:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Note, Project.title)
|
||||
.outerjoin(Project, Note.project_id == Project.id)
|
||||
.where(Note.user_id == user_id, Note.status == "done",
|
||||
Note.deleted_at.is_(None), Note.completed_at.isnot(None),
|
||||
Note.completed_at >= cutoff)
|
||||
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
|
||||
)).all()
|
||||
return [{"id": n.id, "title": n.title, "project_title": ptitle,
|
||||
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
|
||||
|
||||
|
||||
async def _upcoming_events(user_id: int) -> list[dict]:
|
||||
from fabledassistant.services import events as events_svc
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
|
||||
out = []
|
||||
for e in rows:
|
||||
d = e if isinstance(e, dict) else e.to_dict()
|
||||
out.append({"id": d["id"], "title": d["title"],
|
||||
"start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)})
|
||||
return out
|
||||
|
||||
|
||||
async def _week_stats(user_id: int) -> dict:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
||||
async with async_session() as session:
|
||||
row = (await session.execute(
|
||||
select(
|
||||
func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff),
|
||||
func.count(Note.id).filter(Note.status.in_(_OPEN)),
|
||||
func.count(Note.id).filter(Note.status == "in_progress"),
|
||||
func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)),
|
||||
).where(Note.user_id == user_id, Note.deleted_at.is_(None))
|
||||
)).one()
|
||||
return {"completed_this_week": row[0], "open_total": row[1],
|
||||
"in_progress": row[2], "active_plans": row[3]}
|
||||
@@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found():
|
||||
await update_task(task_id=999, status="done")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_milestone_zero_is_omitted():
|
||||
"""milestone_id=0 is 'leave unchanged' — must not reach the service."""
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, milestone_id=0)
|
||||
assert "milestone_id" not in mock.call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_milestone_positive_is_set():
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, milestone_id=42)
|
||||
assert mock.call_args.kwargs["milestone_id"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_milestone_negative_one_clears():
|
||||
"""milestone_id=-1 clears the milestone (sets the column NULL)."""
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, milestone_id=-1)
|
||||
assert mock.call_args.kwargs["milestone_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_clearing_project_also_clears_milestone():
|
||||
"""project_id=-1 clears the project and, with it, the milestone."""
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, project_id=-1)
|
||||
assert mock.call_args.kwargs["project_id"] is None
|
||||
assert mock.call_args.kwargs["milestone_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_log_returns_log_dict():
|
||||
log = MagicMock()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Structural tests for the dashboard blueprint."""
|
||||
|
||||
|
||||
def test_dashboard_blueprint_registered():
|
||||
from fabledassistant.routes.dashboard import dashboard_bp
|
||||
assert dashboard_bp.name == "dashboard"
|
||||
assert dashboard_bp.url_prefix == "/api/dashboard"
|
||||
|
||||
|
||||
def test_dashboard_blueprint_registered_in_app():
|
||||
from fabledassistant.app import create_app
|
||||
app = create_app()
|
||||
assert "dashboard" in app.blueprints
|
||||
|
||||
|
||||
def test_dashboard_handler_callable():
|
||||
from fabledassistant.routes import dashboard as dashboard_routes
|
||||
assert callable(dashboard_routes.get_dashboard)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""build_dashboard composition + helpers (services/dashboard.py).
|
||||
|
||||
Query semantics (ranking/caps/owner-scope) are exercised by manual smoke —
|
||||
the repo's unit tests mock the DB, so SQL isn't executed here. These cover the
|
||||
pure helpers and the section-isolation contract.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_task_row_maps_fields():
|
||||
from fabledassistant.services.dashboard import _task_row
|
||||
n = MagicMock()
|
||||
n.id = 5
|
||||
n.title = "Wire reminders"
|
||||
n.status = "in_progress"
|
||||
n.priority = None
|
||||
assert _task_row(n) == {
|
||||
"id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_returns_value_then_empty_on_error():
|
||||
from fabledassistant.services.dashboard import _safe
|
||||
|
||||
async def ok():
|
||||
return [1, 2, 3]
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("section blew up")
|
||||
|
||||
assert await _safe(ok(), []) == [1, 2, 3]
|
||||
# a failing section returns the supplied empty default, not an exception
|
||||
assert await _safe(boom(), []) == []
|
||||
assert await _safe(boom(), {}) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_dashboard_composes_sections():
|
||||
import fabledassistant.services.dashboard as dash
|
||||
with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \
|
||||
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
|
||||
patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \
|
||||
patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})):
|
||||
out = await dash.build_dashboard(user_id=1)
|
||||
assert out == {
|
||||
"active_projects": ["P"],
|
||||
"recently_completed": ["done"],
|
||||
"upcoming_events": ["evt"],
|
||||
"week_stats": {"open_total": 4},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_dashboard_isolates_failing_section():
|
||||
import fabledassistant.services.dashboard as dash
|
||||
with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \
|
||||
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
|
||||
patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \
|
||||
patch.object(dash, "_week_stats", AsyncMock(return_value={})):
|
||||
out = await dash.build_dashboard(user_id=1)
|
||||
# failing section degrades to its empty default; others still populate
|
||||
assert out["active_projects"] == []
|
||||
assert out["recently_completed"] == ["done"]
|
||||
Reference in New Issue
Block a user