Lessons step 7 — a lesson is readable, writable and browsable by a human (#3734) #169
@@ -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<LessonListResponse> {
|
||||||
|
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<LessonListResponse>(`/api/lessons${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLesson(id: number): Promise<Lesson> {
|
||||||
|
return apiGet<Lesson>(`/api/lessons/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLesson(payload: LessonPayload): Promise<Lesson> {
|
||||||
|
return apiPost<Lesson>("/api/lessons", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLesson(
|
||||||
|
id: number,
|
||||||
|
payload: LessonPayload,
|
||||||
|
): Promise<Lesson> {
|
||||||
|
return apiPatch<Lesson>(`/api/lessons/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLesson(
|
||||||
|
id: number,
|
||||||
|
): Promise<{ deleted: number; deleted_batch_id: string }> {
|
||||||
|
return apiDelete<{ deleted: number; deleted_batch_id: string }>(
|
||||||
|
`/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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* "What was learned from this record" — the reverse of a lesson's
|
||||||
|
* `learned_from`.
|
||||||
|
*
|
||||||
|
* THE DIRECTION THAT GETS FORGOTTEN, and arguably the more useful one. The
|
||||||
|
* forward link is easy to remember because the lesson's author types it; this
|
||||||
|
* one has no author and so tends never to get built. A reader opening an old
|
||||||
|
* issue wants to know what came out of it, and without this the relation is
|
||||||
|
* navigable only from the lesson's side.
|
||||||
|
*
|
||||||
|
* A COMPONENT rather than markup inside the task editor, because the same
|
||||||
|
* question is worth answering on any record a lesson can cite — an issue, a
|
||||||
|
* spike, a dev-log. One panel, mounted wherever that is true, instead of the
|
||||||
|
* shape being written a second time the first time someone wants it on notes
|
||||||
|
* (#3207).
|
||||||
|
*
|
||||||
|
* SILENT WHEN EMPTY. Most records taught no lesson, and a panel that renders
|
||||||
|
* "None yet" on every page is a panel people learn to skip — which costs the
|
||||||
|
* pages where it does have something to say.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { lessonsTaughtBy, type Lesson } from "@/api/lessons";
|
||||||
|
|
||||||
|
const props = defineProps<{ recordId: number }>();
|
||||||
|
|
||||||
|
const lessons = ref<Lesson[]>([]);
|
||||||
|
// No error surface on purpose: this is a secondary panel beside the record the
|
||||||
|
// reader actually came for, and a red box about a failed side-query would be
|
||||||
|
// louder than the thing it failed to fetch. It stays silent and stays absent.
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loaded.value = false;
|
||||||
|
lessons.value = [];
|
||||||
|
if (!props.recordId) return;
|
||||||
|
try {
|
||||||
|
const res = await lessonsTaughtBy(props.recordId);
|
||||||
|
lessons.value = res.lessons;
|
||||||
|
} catch {
|
||||||
|
lessons.value = [];
|
||||||
|
} finally {
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.recordId, load);
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-if="loaded && lessons.length" class="ltp">
|
||||||
|
<h3 class="ltp-label">What was learned from this</h3>
|
||||||
|
<ul class="ltp-list">
|
||||||
|
<li v-for="l in lessons" :key="l.id" class="ltp-item">
|
||||||
|
<router-link :to="`/lessons/${l.id}`" class="ltp-link">
|
||||||
|
{{ l.what || l.title }}
|
||||||
|
</router-link>
|
||||||
|
<!-- The trigger travels with the row. A lesson listed without it is a
|
||||||
|
claim with the half that says when it matters left off. -->
|
||||||
|
<p v-if="l.when_to_apply" class="ltp-trigger">
|
||||||
|
{{ l.when_to_apply }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ltp {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.ltp-label {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.ltp-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.ltp-item { margin-bottom: 0.7rem; }
|
||||||
|
.ltp-link { color: var(--fs-accent); font-size: 0.9rem; }
|
||||||
|
.ltp-trigger {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -69,6 +69,25 @@ const router = createRouter({
|
|||||||
name: "note-edit",
|
name: "note-edit",
|
||||||
component: () => import("@/views/NoteEditorView.vue"),
|
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",
|
path: "/snippets",
|
||||||
name: "snippets",
|
name: "snippets",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
Lightbulb,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -26,7 +27,7 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "task" | "process" | "snippet";
|
note_type: "note" | "task" | "process" | "snippet" | "lesson";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -46,7 +47,8 @@ interface KnowledgeItem {
|
|||||||
|
|
||||||
// ─── The facet vocabulary ─────────────────────────────────────────────────────
|
// ─── The facet vocabulary ─────────────────────────────────────────────────────
|
||||||
// Mirrors services/knowledge._FACETS, which is where it is defined for real.
|
// 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
|
// a task KIND (`task` for any, else issue / spike) — because that is what this
|
||||||
// feed actually holds.
|
// 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
|
// 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
|
// `issue` — 17% of every task here — went without one (#3128). Those rows are
|
||||||
// still reachable under Tasks, wearing a Plan badge.
|
// 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
|
// The facets that select TASKS. Kinds are subsets of `task`, so any of them
|
||||||
// means the duplicate report should be comparing tasks.
|
// means the duplicate report should be comparing tasks.
|
||||||
@@ -67,6 +69,7 @@ const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
|
|||||||
["spike", "Spikes"],
|
["spike", "Spikes"],
|
||||||
["snippet", "Snippets"],
|
["snippet", "Snippets"],
|
||||||
["process", "Processes"],
|
["process", "Processes"],
|
||||||
|
["lesson", "Lessons"],
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── View mode ────────────────────────────────────────────────────────────────
|
// ─── View mode ────────────────────────────────────────────────────────────────
|
||||||
@@ -153,6 +156,11 @@ function createNew(type: string) {
|
|||||||
newNoteMenuOpen.value = false;
|
newNoteMenuOpen.value = false;
|
||||||
if (type === "task") {
|
if (type === "task") {
|
||||||
router.push("/tasks/new");
|
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 {
|
} else {
|
||||||
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
||||||
}
|
}
|
||||||
@@ -328,6 +336,8 @@ function openItem(item: KnowledgeItem) {
|
|||||||
router.push(`/tasks/${item.id}`);
|
router.push(`/tasks/${item.id}`);
|
||||||
} else if (item.note_type === 'snippet') {
|
} else if (item.note_type === 'snippet') {
|
||||||
router.push(`/snippets/${item.id}`);
|
router.push(`/snippets/${item.id}`);
|
||||||
|
} else if (item.note_type === 'lesson') {
|
||||||
|
router.push(`/lessons/${item.id}`);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/notes/${item.id}`);
|
router.push(`/notes/${item.id}`);
|
||||||
}
|
}
|
||||||
@@ -417,6 +427,10 @@ onUnmounted(() => {
|
|||||||
<Workflow :size="16" />
|
<Workflow :size="16" />
|
||||||
Process
|
Process
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="createNew('lesson')">
|
||||||
|
<Lightbulb :size="16" />
|
||||||
|
Lesson
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -574,6 +588,7 @@ onUnmounted(() => {
|
|||||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||||
<span v-else-if="item.note_type === 'snippet'">Snippet</span>
|
<span v-else-if="item.note_type === 'snippet'">Snippet</span>
|
||||||
|
<span v-else-if="item.note_type === 'lesson'">Lesson</span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
|
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
|
||||||
speaks the vocabulary of this view's type filter (note / task /
|
speaks the vocabulary of this view's type filter (note / task /
|
||||||
@@ -962,7 +977,11 @@ onUnmounted(() => {
|
|||||||
isn't an alarm reads better as plain. Standard body pair, so the contrast
|
isn't an alarm reads better as plain. Standard body pair, so the contrast
|
||||||
is the one the palette already guarantees. */
|
is the one the palette already guarantees. */
|
||||||
.badge--snippet,
|
.badge--snippet,
|
||||||
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
|
.badge--process,
|
||||||
|
/* A lesson joins the neutral pair for the same reason, and one of its own: it
|
||||||
|
binds nobody. A hue here would make the softest record in the corpus look
|
||||||
|
like the loudest, beside a rule that actually is binding. */
|
||||||
|
.badge--lesson { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
|
||||||
|
|
||||||
.k-card-body { flex: 1; padding-right: 40px; }
|
.k-card-body { flex: 1; padding-right: 40px; }
|
||||||
.k-card-title {
|
.k-card-title {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Read a LESSON, and see both ends of what it came from.
|
||||||
|
*
|
||||||
|
* THE TRIGGER LEADS. A lesson is retrieved by the situation it applies to, so
|
||||||
|
* the page opens with that situation rather than with the claim — the same
|
||||||
|
* ordering the editor uses, and for the same reason: the trigger is the half
|
||||||
|
* a reader needs first to decide whether this is for them.
|
||||||
|
*
|
||||||
|
* GLOBAL BY DEFAULT HAS TO BE LEGIBLE. A lesson written on one project will
|
||||||
|
* surface on another, which reads as a bug unless the page says why. The
|
||||||
|
* origin line does exactly that, and says it as a property of the kind rather
|
||||||
|
* than as an apology.
|
||||||
|
*
|
||||||
|
* IT BINDS NOBODY, and the page says so once, plainly. That sentence is the
|
||||||
|
* difference between this kind and a rule, and it is the reason the kind
|
||||||
|
* exists (#3727) — sessions were proposing rules for things that should never
|
||||||
|
* have bound anyone.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { deleteLesson, getLesson, type Lesson } from "@/api/lessons";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useToastStore();
|
||||||
|
|
||||||
|
const lesson = ref<Lesson | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const confirming = ref(false);
|
||||||
|
|
||||||
|
const lessonId = computed(() => Number(route.params.id));
|
||||||
|
|
||||||
|
/** The insight rendered — the body minus the lines the service composed, so
|
||||||
|
* the trigger is not printed twice on a page that already leads with it. */
|
||||||
|
const insightHtml = computed(() =>
|
||||||
|
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Where each source opens. A task and a note live at different routes, and a
|
||||||
|
* link that guesses wrong is worse than one that is plain. */
|
||||||
|
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
|
||||||
|
if (rec.is_task) return `/tasks/${rec.id}`;
|
||||||
|
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
|
||||||
|
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
|
||||||
|
return `/notes/${rec.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
lesson.value = await getLesson(lessonId.value);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErrorMessage(e, "Failed to load this lesson");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
confirming.value = false;
|
||||||
|
try {
|
||||||
|
await deleteLesson(lessonId.value);
|
||||||
|
toast.show("Lesson moved to trash");
|
||||||
|
router.push("/knowledge?type=lesson");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(apiErrorMessage(e, "Failed to delete this lesson"), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(lessonId, load);
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="lesson-detail">
|
||||||
|
<p v-if="loading" class="ld-muted">Loading…</p>
|
||||||
|
<p v-else-if="error" class="ld-error" role="alert">{{ error }}</p>
|
||||||
|
|
||||||
|
<article v-else-if="lesson">
|
||||||
|
<header class="ld-head">
|
||||||
|
<span class="ld-kind">Lesson</span>
|
||||||
|
<!-- Said once, plainly. It is the whole difference from a rule. -->
|
||||||
|
<span class="ld-binds">binds nobody</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- The trigger leads, in its own block: it is what a reader needs first
|
||||||
|
to decide whether this applies to them. -->
|
||||||
|
<section class="ld-trigger">
|
||||||
|
<h2 class="ld-trigger-label">When this applies</h2>
|
||||||
|
<p class="ld-trigger-text">{{ lesson.when_to_apply }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h1 class="ld-what">{{ lesson.what }}</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="insightHtml"
|
||||||
|
class="ld-insight markdown-body"
|
||||||
|
v-html="insightHtml"
|
||||||
|
/>
|
||||||
|
<p v-else class="ld-muted ld-empty">
|
||||||
|
No detail was recorded — the claim above is the whole lesson.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- What taught it. The provenance is the point: a lesson that loses its
|
||||||
|
incidents loses its evidence. -->
|
||||||
|
<section
|
||||||
|
v-if="lesson.learned_from_records?.length"
|
||||||
|
class="ld-panel"
|
||||||
|
>
|
||||||
|
<h2 class="ld-panel-label">Learned from</h2>
|
||||||
|
<ul class="ld-sources">
|
||||||
|
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
|
||||||
|
<router-link :to="sourceHref(rec)" class="ld-link">
|
||||||
|
{{ rec.title }}
|
||||||
|
</router-link>
|
||||||
|
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="lesson.tags?.length" class="ld-tags">
|
||||||
|
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Global-by-default, stated as a property rather than an apology. A
|
||||||
|
lesson meeting you on a project it was not written on is the kind
|
||||||
|
working, and the page has to say so or it reads as a bug. -->
|
||||||
|
<footer class="ld-origin">
|
||||||
|
<p v-if="lesson.project_id">
|
||||||
|
Learned on another project, and offered everywhere — a lesson is
|
||||||
|
retrieved by the situation it names, never by where it was written.
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Not tied to a project. Offered wherever the situation it names comes
|
||||||
|
up.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div class="ld-actions">
|
||||||
|
<router-link :to="`/lessons/${lesson.id}/edit`" class="ld-primary">
|
||||||
|
Edit
|
||||||
|
</router-link>
|
||||||
|
<button type="button" class="ld-ghost" @click="confirming = true">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-if="confirming"
|
||||||
|
title="Delete this lesson?"
|
||||||
|
message="It moves to the trash and can be restored."
|
||||||
|
@confirm="remove"
|
||||||
|
@cancel="confirming = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-detail {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--fs-layout-page-pad);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ld-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
/* Neutral, matching the browse badge. A hue here would make the softest
|
||||||
|
record in the corpus look like the loudest. */
|
||||||
|
.ld-kind {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ld-binds { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.ld-trigger {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-left: 2px solid var(--fs-accent);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.ld-trigger-label {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.ld-trigger-text { margin: 0; font-size: 0.98rem; line-height: 1.5; }
|
||||||
|
|
||||||
|
.ld-what {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ld-insight { line-height: 1.6; font-size: 0.93rem; }
|
||||||
|
.ld-empty { font-style: italic; }
|
||||||
|
|
||||||
|
.ld-panel {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.ld-panel-label {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.ld-sources { margin: 0; padding-left: 1.1rem; }
|
||||||
|
.ld-sources li { margin-bottom: 0.3rem; font-size: 0.9rem; }
|
||||||
|
.ld-link { color: var(--fs-accent); }
|
||||||
|
.ld-source-status {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ld-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.25rem; }
|
||||||
|
|
||||||
|
.ld-origin {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ld-origin p { margin: 0; }
|
||||||
|
|
||||||
|
.ld-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.ld-primary {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: var(--fs-accent);
|
||||||
|
color: var(--fs-accent-fg);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.ld-ghost {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ld-muted { color: var(--fs-text-secondary); font-size: 0.88rem; }
|
||||||
|
.ld-error {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--fs-error) 12%, var(--fs-surface-raised));
|
||||||
|
color: color-mix(in srgb, var(--fs-error) 55%, var(--fs-text-primary));
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Write or edit a LESSON — a transferable insight, found by the situation it
|
||||||
|
* applies to rather than by its topic.
|
||||||
|
*
|
||||||
|
* THREE FIELDS, NOT ONE MARKDOWN BOX. The title and body are composed by the
|
||||||
|
* service from `what`, `when_to_apply` and `insight`; this form never sends a
|
||||||
|
* document. That is the design milestone 385 step 1 settled on, and the
|
||||||
|
* evidence for it is blunt: the snippet corpus carries a trigger on every
|
||||||
|
* record with no guard anywhere, because a service composes the title from a
|
||||||
|
* named parameter. What is at 100% is a named structured field — not a writer
|
||||||
|
* remembering a convention.
|
||||||
|
*
|
||||||
|
* THE TRIGGER IS THE FIELD THAT CANNOT BE MISSED, so it is given the most
|
||||||
|
* room, its own explanation, and a save button that refuses without it. A
|
||||||
|
* lesson with no trigger saves, reads correctly in every listing, and never
|
||||||
|
* surfaces — and there is nothing to notice afterwards, because it looks
|
||||||
|
* exactly like a lesson that works. The form is where that gets caught.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import {
|
||||||
|
createLesson,
|
||||||
|
getLesson,
|
||||||
|
updateLesson,
|
||||||
|
type Lesson,
|
||||||
|
} from "@/api/lessons";
|
||||||
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||||
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
import { useNotesStore } from "@/stores/notes";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useToastStore();
|
||||||
|
const notesStore = useNotesStore();
|
||||||
|
|
||||||
|
const lessonId = computed(() => {
|
||||||
|
const raw = route.params.id;
|
||||||
|
return raw ? Number(raw) : null;
|
||||||
|
});
|
||||||
|
const isEdit = computed(() => lessonId.value !== null);
|
||||||
|
|
||||||
|
const what = ref("");
|
||||||
|
const whenToApply = ref("");
|
||||||
|
const insight = ref("");
|
||||||
|
const tags = ref<string[]>([]);
|
||||||
|
const projectId = ref<number | null>(null);
|
||||||
|
const learnedFrom = ref<number[]>([]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
// The near-duplicate gate's answer, held so the writer can read it and then
|
||||||
|
// decide — rather than being silently overridden or silently blocked.
|
||||||
|
const duplicate = ref<{ id: number; title: string } | null>(null);
|
||||||
|
|
||||||
|
/** The composed title, shown live. The writer is agreeing to a document they
|
||||||
|
* can see, which is the same reason `create_rule` shows a rule's statement
|
||||||
|
* before asking for a yes. */
|
||||||
|
const previewTitle = computed(() => {
|
||||||
|
const subject = what.value.trim();
|
||||||
|
const trigger = whenToApply.value.trim();
|
||||||
|
if (subject && trigger) return `${subject} — ${trigger}`;
|
||||||
|
return subject || trigger;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSave = computed(
|
||||||
|
() => what.value.trim().length > 0 && whenToApply.value.trim().length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!isEdit.value || lessonId.value === null) return;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const lesson: Lesson = await getLesson(lessonId.value);
|
||||||
|
what.value = lesson.what;
|
||||||
|
whenToApply.value = lesson.when_to_apply;
|
||||||
|
// The insight WITHOUT the composed lines, so saving doesn't accumulate a
|
||||||
|
// copy of the trigger line on every edit.
|
||||||
|
insight.value = lesson.insight;
|
||||||
|
tags.value = lesson.tags ?? [];
|
||||||
|
projectId.value = lesson.project_id;
|
||||||
|
learnedFrom.value = lesson.learned_from ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErrorMessage(e, "Failed to load this lesson");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(force = false) {
|
||||||
|
if (!canSave.value || saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
error.value = null;
|
||||||
|
duplicate.value = null;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
what: what.value.trim(),
|
||||||
|
when_to_apply: whenToApply.value.trim(),
|
||||||
|
insight: insight.value,
|
||||||
|
tags: tags.value,
|
||||||
|
learned_from: learnedFrom.value,
|
||||||
|
project_id: projectId.value,
|
||||||
|
...(force ? { force: true } : {}),
|
||||||
|
};
|
||||||
|
const saved = isEdit.value && lessonId.value !== null
|
||||||
|
? await updateLesson(lessonId.value, payload)
|
||||||
|
: await createLesson(payload);
|
||||||
|
toast.show(isEdit.value ? "Lesson updated" : "Lesson recorded");
|
||||||
|
router.push(`/lessons/${saved.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
// A 409 is the duplicate gate, not a failure: it hands back the record
|
||||||
|
// that already covers this moment so the writer can improve that one
|
||||||
|
// instead of standing a second beside it.
|
||||||
|
const body = (e as { status?: number; body?: Record<string, unknown> });
|
||||||
|
if (body?.status === 409 && body.body) {
|
||||||
|
const existing = body.body as { id?: number; title?: string };
|
||||||
|
if (existing.id) {
|
||||||
|
duplicate.value = { id: existing.id, title: existing.title ?? "" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error.value = apiErrorMessage(e, "Failed to save this lesson");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(lessonId, load);
|
||||||
|
onMounted(() => {
|
||||||
|
load();
|
||||||
|
// Pre-fill from the link that brought you here, the same convention the
|
||||||
|
// snippet editor uses. Left null it is a lesson with no recorded origin,
|
||||||
|
// which is valid — `project_id` says where a lesson was LEARNED and was
|
||||||
|
// never the limit on where it can be found (step 3).
|
||||||
|
if (!isEdit.value && route.query.projectId) {
|
||||||
|
projectId.value = Number(route.query.projectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="lesson-editor">
|
||||||
|
<header class="le-head">
|
||||||
|
<h1>{{ isEdit ? "Edit lesson" : "Record a lesson" }}</h1>
|
||||||
|
<p class="le-sub">
|
||||||
|
Something worth knowing, kept so a later session meets it at the moment
|
||||||
|
it applies. A lesson binds nobody.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="error" class="le-error" role="alert">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="duplicate" class="le-dupe" role="alert">
|
||||||
|
<p>
|
||||||
|
<strong>A lesson already covers this moment.</strong>
|
||||||
|
Improving that one keeps what was learned in a single place — two
|
||||||
|
lessons under one trigger compete for the same slot, so the second
|
||||||
|
displaces the first rather than adding to it.
|
||||||
|
</p>
|
||||||
|
<div class="le-dupe-actions">
|
||||||
|
<router-link :to="`/lessons/${duplicate.id}`" class="le-link">
|
||||||
|
Open “{{ duplicate.title }}”
|
||||||
|
</router-link>
|
||||||
|
<button type="button" class="le-ghost" @click="save(true)">
|
||||||
|
Record it anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="le-muted">Loading…</p>
|
||||||
|
|
||||||
|
<form v-else class="le-form" @submit.prevent="save()">
|
||||||
|
<!-- The trigger comes FIRST, before the claim. It is what makes a lesson
|
||||||
|
findable, and putting it second invites it to be treated as an
|
||||||
|
afterthought to the thing the writer arrived wanting to say. -->
|
||||||
|
<label class="le-field le-field--primary">
|
||||||
|
<span class="le-label">When does this apply?</span>
|
||||||
|
<span class="le-hint">
|
||||||
|
The situation, in the words it will present itself in — what someone
|
||||||
|
would be seeing, saying, or about to do. “A test fails on code you
|
||||||
|
believe is correct” is a trigger. “Testing” is a topic, and a topic
|
||||||
|
matches everything and surfaces for nothing.
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
v-model="whenToApply"
|
||||||
|
class="le-input le-textarea"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
placeholder="a CI run has sat in_progress far longer than its suite takes"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="le-field">
|
||||||
|
<span class="le-label">What did you learn?</span>
|
||||||
|
<span class="le-hint">The claim itself, in one line, as you would say it.</span>
|
||||||
|
<input
|
||||||
|
v-model="what"
|
||||||
|
class="le-input"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Read the job log before waiting longer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="le-field">
|
||||||
|
<span class="le-label">The detail <em>(optional)</em></span>
|
||||||
|
<span class="le-hint">
|
||||||
|
What you would want handed to you in the same situation next time —
|
||||||
|
the evidence, the reasoning, the thing that is not obvious.
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
v-model="insight"
|
||||||
|
class="le-input le-textarea le-textarea--tall"
|
||||||
|
rows="10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="le-field">
|
||||||
|
<span class="le-label">Tags</span>
|
||||||
|
<TagInput
|
||||||
|
v-model="tags"
|
||||||
|
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="le-field">
|
||||||
|
<span class="le-label">Learned on <em>(optional)</em></span>
|
||||||
|
<span class="le-hint">
|
||||||
|
Where this was learned. Kept as a fact about its origin — a lesson is
|
||||||
|
retrievable from every project regardless, which is the point of the
|
||||||
|
kind.
|
||||||
|
</span>
|
||||||
|
<ProjectSelector v-model="projectId" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- The composed document, shown before saving. The writer is agreeing
|
||||||
|
to a title they can read, not to one assembled out of sight. -->
|
||||||
|
<div v-if="previewTitle" class="le-preview">
|
||||||
|
<span class="le-preview-label">Stored as</span>
|
||||||
|
<p class="le-preview-title">{{ previewTitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="le-actions">
|
||||||
|
<button type="submit" class="le-primary" :disabled="!canSave || saving">
|
||||||
|
{{ saving ? "Saving…" : isEdit ? "Save lesson" : "Record lesson" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="le-ghost" @click="router.back()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<!-- Says WHY it is disabled. A greyed button with no reason is the
|
||||||
|
thing that gets clicked repeatedly and then worked around. -->
|
||||||
|
<span v-if="!canSave" class="le-muted le-why">
|
||||||
|
A lesson needs both a trigger and a claim — without the trigger it
|
||||||
|
would save and never reach anyone.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-editor {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--fs-layout-page-pad);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.le-head h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.le-sub {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.le-form { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
|
|
||||||
|
.le-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
|
||||||
|
/* The trigger gets visible weight, because it is the field whose absence is
|
||||||
|
invisible afterwards. */
|
||||||
|
.le-field--primary {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-lg);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.le-label { font-weight: 500; font-size: 0.9rem; }
|
||||||
|
.le-label em { font-style: normal; color: var(--fs-text-tertiary); font-weight: 400; }
|
||||||
|
.le-hint {
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.le-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: var(--fs-surface-page);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.le-input:focus-visible {
|
||||||
|
outline: 2px solid var(--fs-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.le-textarea { resize: vertical; line-height: 1.5; }
|
||||||
|
.le-textarea--tall { font-family: var(--fs-font-mono); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.le-preview {
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-left: 2px solid var(--fs-accent);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
}
|
||||||
|
.le-preview-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.le-preview-title { margin: 0.2rem 0 0; font-size: 0.92rem; }
|
||||||
|
|
||||||
|
.le-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.le-primary {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: var(--fs-accent);
|
||||||
|
color: var(--fs-accent-fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.le-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.le-ghost {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.le-muted { color: var(--fs-text-secondary); font-size: 0.85rem; }
|
||||||
|
.le-why { flex-basis: 100%; }
|
||||||
|
|
||||||
|
.le-error {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--fs-error) 12%, var(--fs-surface-raised));
|
||||||
|
color: color-mix(in srgb, var(--fs-error) 55%, var(--fs-text-primary));
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.le-dupe {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-lg);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.le-dupe p { margin: 0 0 0.6rem; line-height: 1.5; }
|
||||||
|
.le-dupe-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; }
|
||||||
|
.le-link { color: var(--fs-accent); }
|
||||||
|
</style>
|
||||||
@@ -23,6 +23,7 @@ import WordCount from "@/components/WordCount.vue";
|
|||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||||
|
import LessonsTaughtPanel from "@/components/LessonsTaughtPanel.vue";
|
||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
@@ -546,6 +547,13 @@ useEditorGuards(dirty, save);
|
|||||||
<!-- Work log -->
|
<!-- Work log -->
|
||||||
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
|
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
|
||||||
|
|
||||||
|
<!-- What came OUT of this record. The forward link is typed by
|
||||||
|
the lesson's author; this direction has no author and so
|
||||||
|
tends never to get built — but a reader opening an old
|
||||||
|
issue wants to know what was learned from it. Silent when
|
||||||
|
there is nothing, because most records taught no lesson. -->
|
||||||
|
<LessonsTaughtPanel v-if="taskId" :record-id="taskId" />
|
||||||
|
|
||||||
<!-- Applicable rules (plan tasks only) -->
|
<!-- Applicable rules (plan tasks only) -->
|
||||||
<PlanRulesPanel
|
<PlanRulesPanel
|
||||||
v-if="store.currentTask?.task_kind === 'plan' && store.currentTask?.project_id"
|
v-if="store.currentTask?.task_kind === 'plan' && store.currentTask?.project_id"
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ async def get_lesson_route(lesson_id: int):
|
|||||||
s.to_dict()
|
s.to_dict()
|
||||||
for s in await systems_svc.list_record_systems(note.user_id, lesson_id)
|
for s in await systems_svc.list_record_systems(note.user_id, lesson_id)
|
||||||
]
|
]
|
||||||
|
# Resolved, not bare ids: "#4181" on a page tells a reader nothing about
|
||||||
|
# whether it is worth opening, and the provenance is the point of a lesson.
|
||||||
|
out["learned_from_records"] = await lessons_svc.source_records(
|
||||||
|
uid, out["learned_from"]
|
||||||
|
)
|
||||||
out.update(await describe_provenance(uid, note))
|
out.update(await describe_provenance(uid, note))
|
||||||
out["usage"] = (await usage_for_notes([lesson_id])).get(
|
out["usage"] = (await usage_for_notes([lesson_id])).get(
|
||||||
lesson_id, empty_usage()
|
lesson_id, empty_usage()
|
||||||
|
|||||||
@@ -445,6 +445,50 @@ def lesson_to_dict(note) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def source_records(user_id: int, ids: list[int]) -> list[dict]:
|
||||||
|
"""The records that taught a lesson, resolved to something linkable.
|
||||||
|
|
||||||
|
`learned_from` is a list of bare ids, which is right for storage and
|
||||||
|
useless on a page: "#4181" tells a reader nothing about whether it is
|
||||||
|
worth opening. This resolves each to its title and kind so the UI can
|
||||||
|
label the link, and so a source that has been deleted simply drops out
|
||||||
|
rather than rendering a link to nothing.
|
||||||
|
|
||||||
|
One query for the whole list, not one per id — a lesson with six sources
|
||||||
|
would otherwise be six round trips to draw one panel.
|
||||||
|
|
||||||
|
Share-aware (rule 78). Order follows `ids`, because that order is the
|
||||||
|
writer's: the first source is the one they reached for first.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.services.access import readable_notes_clause
|
||||||
|
|
||||||
|
wanted = normalize_sources(ids)
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Note)
|
||||||
|
.where(Note.id.in_(wanted))
|
||||||
|
.where(Note.deleted_at.is_(None))
|
||||||
|
.where(readable_notes_clause(user_id))
|
||||||
|
)
|
||||||
|
found = {n.id: n for n in result.scalars().all()}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": n.id,
|
||||||
|
"title": n.title,
|
||||||
|
"note_type": n.note_type,
|
||||||
|
"is_task": n.status is not None,
|
||||||
|
"task_kind": n.task_kind,
|
||||||
|
"status": n.status,
|
||||||
|
}
|
||||||
|
for i in wanted
|
||||||
|
if (n := found.get(i)) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
|
async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
|
||||||
"""The lessons drawn FROM one record — the reverse of `learned_from`.
|
"""The lessons drawn FROM one record — the reverse of `learned_from`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user