diff --git a/frontend/src/api/lessons.ts b/frontend/src/api/lessons.ts new file mode 100644 index 0000000..684200d --- /dev/null +++ b/frontend/src/api/lessons.ts @@ -0,0 +1,146 @@ +import type { RecordUsage } from "@/types/usage"; + +import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; + +/** A lesson: a transferable insight, retrievable by the SITUATION it applies + * to rather than by its topic. + * + * The fields mirror what the backend composes and reads back + * (`services/lessons.py::lesson_to_dict`), not the stored row. `title` and + * `body` are DERIVED — the service builds them from `what`, `when_to_apply` + * and `insight` — so an editor sends the three parts and never the document. + * That is the whole design: the trigger ends up in the title and again at the + * head of the body, which is what makes a lesson rank on when it applies. */ +export interface Lesson { + id: number; + /** The composed document title, `{what} — {when_to_apply}`. Read-only. */ + title: string; + /** The composed body. Read-only — edit `insight` instead. */ + body: string; + /** The claim itself, as you would say it. */ + what: string; + /** WHEN this applies — the situation, in the words it presents itself in. + * The entire retrieval story: a lesson without one saves, reads correctly + * and never surfaces, so both doors refuse an empty one. */ + when_to_apply: string; + /** The body with the composed lines stripped — what an edit form binds to, + * so saving doesn't accumulate a copy of the trigger line per save. */ + insight: string; + /** Ids of the records that taught this — issues, tasks or notes. */ + learned_from: number[]; + /** The same sources RESOLVED, sent by the detail route only. A bare "#4181" + * on a page tells a reader nothing about whether it is worth opening, and + * the provenance is the point of a lesson — one that loses its incidents + * loses its evidence. A source that has been deleted drops out rather than + * rendering a link to nothing. */ + learned_from_records?: { + id: number; + title: string; + note_type: string; + is_task: boolean; + task_kind: string | null; + status: string | null; + }[]; + tags: string[]; + note_type: string; + /** Where it was LEARNED. Kept as a fact, but not a limit on where it can be + * found: a lesson is retrievable from every project (milestone 385 step 3). */ + project_id: number | null; + permission?: string; + created_at: string | null; + updated_at: string | null; + systems?: { id: number; name: string }[]; + usage?: RecordUsage; + /** Set when another user owns this record. */ + shared?: boolean; + owner?: string | null; +} + +/** A row in the browse listing — the trigger travels with it, because a list + * of lessons without their triggers is a list of claims with the half that + * says when each one matters left off. */ +export interface LessonListRow { + id: number; + title: string; + tags: string[]; + when_to_apply?: string; + snippet?: string; + shared?: boolean; + owner?: string | null; +} + +export interface LessonListResponse { + lessons: LessonListRow[]; + total: number; +} + +/** What the create/update forms send. `what` and `when_to_apply` are required + * on create; every field is optional on update, and the service re-composes + * the whole document from the merged set — so a partial save can never leave + * the title and body disagreeing about the trigger. */ +export interface LessonPayload { + what?: string; + when_to_apply?: string; + insight?: string; + learned_from?: number[]; + tags?: string[]; + project_id?: number | null; + system_ids?: number[]; + /** Deliberate override of the near-duplicate gate, once the writer has seen + * the warning. Two lessons under one trigger compete for one reserved slot, + * so a duplicate displaces rather than merely clutters. */ + force?: boolean; +} + +export function listLessons(params: { + q?: string; + tag?: string; + project_id?: number; + limit?: number; + offset?: number; +} = {}): Promise { + const qs = new URLSearchParams(); + if (params.q) qs.set("q", params.q); + if (params.tag) qs.set("tag", params.tag); + if (params.project_id) qs.set("project_id", String(params.project_id)); + if (params.limit != null) qs.set("limit", String(params.limit)); + if (params.offset != null) qs.set("offset", String(params.offset)); + const suffix = qs.toString() ? `?${qs}` : ""; + return apiGet(`/api/lessons${suffix}`); +} + +export function getLesson(id: number): Promise { + return apiGet(`/api/lessons/${id}`); +} + +export function createLesson(payload: LessonPayload): Promise { + return apiPost("/api/lessons", payload); +} + +export function updateLesson( + id: number, + payload: LessonPayload, +): Promise { + return apiPatch(`/api/lessons/${id}`, payload); +} + +/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the + * established shape here (snippets delete the same way): the batch id is in + * the response, but no caller has needed it and inventing a second delete + * helper to carry it would be the duplication, not the feature. */ +export function deleteLesson(id: number): Promise { + return apiDelete(`/api/lessons/${id}`); +} + +/** The lessons drawn FROM one record — the reverse of `learned_from`. + * + * The direction that gets forgotten, and arguably the more useful one: a + * reader opening an old issue wants to know what was learned from it, and + * without this the relation is only navigable from the lesson's side. */ +export function lessonsTaughtBy( + recordId: number, +): Promise<{ lessons: Lesson[]; taught_by: number }> { + return apiGet<{ lessons: Lesson[]; taught_by: number }>( + `/api/lessons/taught-by/${recordId}`, + ); +} diff --git a/frontend/src/components/LessonsTaughtPanel.vue b/frontend/src/components/LessonsTaughtPanel.vue new file mode 100644 index 0000000..88f93be --- /dev/null +++ b/frontend/src/components/LessonsTaughtPanel.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0d590f7..999f9db 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -69,6 +69,25 @@ const router = createRouter({ name: "note-edit", component: () => import("@/views/NoteEditorView.vue"), }, + { + // Lessons have no list view of their own: the Knowledge browse surface + // is where every kind is enumerated, and a second list would be a + // second vocabulary to keep in step with it (#3128's defect, in + // advance). `/knowledge?type=lesson` is the list. + path: "/lessons/new", + name: "lesson-new", + component: () => import("@/views/LessonEditorView.vue"), + }, + { + path: "/lessons/:id", + name: "lesson-view", + component: () => import("@/views/LessonDetailView.vue"), + }, + { + path: "/lessons/:id/edit", + name: "lesson-edit", + component: () => import("@/views/LessonEditorView.vue"), + }, { path: "/snippets", name: "snippets", diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 61ca964..81932ec 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -12,6 +12,7 @@ import { FileText, CheckSquare, Workflow, + Lightbulb, Search, Share2, ShieldCheck, @@ -26,7 +27,7 @@ const router = useRouter(); interface KnowledgeItem { id: number; - note_type: "note" | "task" | "process" | "snippet"; + note_type: "note" | "task" | "process" | "snippet" | "lesson"; title: string; snippet: string; tags: string[]; @@ -46,7 +47,8 @@ interface KnowledgeItem { // ─── The facet vocabulary ───────────────────────────────────────────────────── // Mirrors services/knowledge._FACETS, which is where it is defined for real. -// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or +// A facet spans BOTH typing axes — a record TYPE (note / process / snippet / +// lesson) or // a task KIND (`task` for any, else issue / spike) — because that is what this // feed actually holds. // @@ -54,7 +56,7 @@ interface KnowledgeItem { // it has no chip: retired in 0066, it kept a chip of its own for longer than // `issue` — 17% of every task here — went without one (#3128). Those rows are // still reachable under Tasks, wearing a Plan badge. -type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process"; +type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process" | "lesson"; // The facets that select TASKS. Kinds are subsets of `task`, so any of them // means the duplicate report should be comparing tasks. @@ -67,6 +69,7 @@ const FACET_CHIPS: [Exclude, string][] = [ ["spike", "Spikes"], ["snippet", "Snippets"], ["process", "Processes"], + ["lesson", "Lessons"], ]; // ─── View mode ──────────────────────────────────────────────────────────────── @@ -153,6 +156,11 @@ function createNew(type: string) { newNoteMenuOpen.value = false; if (type === "task") { router.push("/tasks/new"); + } else if (type === "lesson") { + // Its own editor, not /notes/new?type=lesson: the note editor offers one + // markdown box, and a lesson written that way saves without a trigger and + // never surfaces. The form has to ASK for the field by name. + router.push("/lessons/new"); } else { router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`); } @@ -328,6 +336,8 @@ function openItem(item: KnowledgeItem) { router.push(`/tasks/${item.id}`); } else if (item.note_type === 'snippet') { router.push(`/snippets/${item.id}`); + } else if (item.note_type === 'lesson') { + router.push(`/lessons/${item.id}`); } else { router.push(`/notes/${item.id}`); } @@ -417,6 +427,10 @@ onUnmounted(() => { Process + @@ -574,6 +588,7 @@ onUnmounted(() => { {{ item.task_kind === 'plan' ? 'Plan' : 'Task' }} Process Snippet + Lesson + binds nobody + + + +
+

When this applies

+

{{ lesson.when_to_apply }}

+
+ +

{{ lesson.what }}

+ +
+

+ No detail was recorded — the claim above is the whole lesson. +

+ + +
+

Learned from

+
    +
  • + + {{ rec.title }} + + {{ rec.status }} +
  • +
+
+ +
+ +
+ + +
+

+ Learned on another project, and offered everywhere — a lesson is + retrieved by the situation it names, never by where it was written. +

+

+ Not tied to a project. Offered wherever the situation it names comes + up. +

+
+ +
+ + Edit + + +
+ + + +
+ + + diff --git a/frontend/src/views/LessonEditorView.vue b/frontend/src/views/LessonEditorView.vue new file mode 100644 index 0000000..3cbaf3b --- /dev/null +++ b/frontend/src/views/LessonEditorView.vue @@ -0,0 +1,385 @@ + + +