diff --git a/alembic/versions/0120_family_canon.py b/alembic/versions/0120_family_canon.py new file mode 100644 index 00000000..0a253a51 --- /dev/null +++ b/alembic/versions/0120_family_canon.py @@ -0,0 +1,288 @@ +"""family canon — platforms, family ideas, the adoption ledger and its decision +log (milestone 463 step 1) + +Revision ID: 0120 +Revises: 0119 +Create Date: 2026-10-06 + +When one project solves something every project on the same platform will +meet, that solution becomes FAMILY CANON, and every other project on the +platform answers it: adopted, a variant (with a reason), exempt (with a +reason), or owed. These tables hold that. + +- `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug + is the match key. Seeded with generic technology names only. Nothing here + names an app, a repo or a house convention: the catalog ships to every + install (rule 115). +- `project_platforms` — which platforms a project is (declared / detected / + rejected). Membership is what makes "is this in family?" a lookup. +- `family_ideas` — a NOTE's family state. No new record type: any notes row + (a note, a snippet, a lesson) becomes an idea by gaining one of these. +- `family_idea_platforms` — the platforms an idea is for; the only scope + source. A linked rule topic takes its scope from the idea. +- `family_idea_references` — reference implementations (snippets). +- `family_adoptions` — one row per (project, idea): the project's answer. +- `family_decisions` — append-only log of every promotion and ledger change. + +No backfill. No project has declared a platform and no note is an idea yet; +inventing either would assert a judgment nobody made. The seed rows are +written here verbatim rather than imported, so the migration keeps running +unchanged after any service-side list moves on. +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision = "0120" +down_revision = "0119" +branch_labels = None +depends_on = None + +# One place per whitelist, so each CHECK and the model's tuple cannot drift +# (rule 36: a new value later means DROP + ADD CONSTRAINT in one migration). +_MEMBERSHIP = ("declared", "detected", "rejected") +_IDEA_STATUSES = ("candidate", "canon", "retired") +_ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed") +_REASONED = ("variant", "exempt") +_ACTIONS = ("propose", "promote", "revise", "retire", "assess", "undo") +_DECIDERS = ("agent", "operator", "system") + + +def _in(column: str, values: tuple[str, ...]) -> str: + return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")" + + +# (name, slug, description, markers). A marker with no slash matches a file's +# basename anywhere in a repo; one with a slash matches the repo-relative +# path. Empty markers = declare-only, for a platform that leaves no reliable +# file behind. Generic on purpose — an install adds its own. +_SEED = ( + ("Android app", "android-app", + "A native Android client: an APK or app bundle installed on phones and tablets.", + ["AndroidManifest.xml"]), + ("iOS app", "ios-app", + "A native iOS or iPadOS client built with Xcode.", + ["project.pbxproj"]), + ("Web frontend", "web-frontend", + "A browser client built with a frontend toolchain: single-page apps and server-rendered frontends.", + ["vite.config.*", "svelte.config.*", "vue.config.*", "next.config.*", + "nuxt.config.*", "angular.json"]), + ("Browser extension", "browser-extension", + "An extension installed into a web browser and distributed through its add-on store or by file.", + []), + ("Desktop app", "desktop-app", + "A packaged desktop application for Windows, macOS or Linux.", + ["tauri.conf.json", "electron-builder.*"]), + ("Container image", "container-image", + "Ships as an OCI/Docker image that a host pulls and runs.", + ["Dockerfile", "Containerfile", "*.Dockerfile"]), + ("Go", "go", + "Written in Go.", + ["go.mod"]), + ("Python", "python", + "Written in Python.", + ["pyproject.toml", "setup.py", "requirements.txt"]), + ("Rust", "rust", + "Written in Rust.", + ["Cargo.toml"]), + ("PostgreSQL", "postgresql", + "Keeps its data in PostgreSQL.", + []), + ("GitHub Actions", "github-actions", + "Verified and released by GitHub Actions workflows.", + [".github/workflows/*"]), + ("Gitea / Forgejo Actions", "gitea-actions", + "Verified and released by Gitea or Forgejo Actions workflows.", + [".gitea/workflows/*", ".forgejo/workflows/*"]), +) + + +def upgrade() -> None: + platforms = op.create_table( + "platforms", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("slug", sa.Text(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("markers", postgresql.JSONB(), nullable=False, + server_default=sa.text("'[]'::jsonb")), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_batch_id", sa.Text(), nullable=True), + ) + # Unique among LIVE rows only (the canonical_systems convention). + op.create_index( + "uq_platforms_slug", "platforms", ["slug"], + unique=True, postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.bulk_insert( + platforms, + [ + {"name": name, "slug": slug, "description": description, + "markers": markers, "order_index": index} + for index, (name, slug, description, markers) in enumerate(_SEED) + ], + ) + + op.create_table( + "project_platforms", + sa.Column("project_id", sa.Integer(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), + sa.Column("platform_id", sa.Integer(), + sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True), + sa.Column("state", sa.Text(), nullable=False, server_default="declared"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_check_constraint( + "ck_project_platforms_state", "project_platforms", _in("state", _MEMBERSHIP), + ) + op.create_index("ix_project_platforms_platform_id", "project_platforms", ["platform_id"]) + + op.create_table( + "family_ideas", + sa.Column("note_id", sa.Integer(), + sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), + sa.Column("status", sa.Text(), nullable=False, server_default="candidate"), + sa.Column("applies_when", sa.Text(), nullable=True), + sa.Column("canon_version", sa.Integer(), nullable=False, server_default="1"), + # SET NULL: deleting a topic must not delete the standard it served. + sa.Column("topic_id", sa.BigInteger(), + sa.ForeignKey("rulebook_topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_check_constraint( + "ck_family_ideas_status", "family_ideas", _in("status", _IDEA_STATUSES), + ) + # The first promotion criterion, held by the schema: an idea is not canon + # until it says, in platform terms, when it applies. + op.create_check_constraint( + "ck_family_ideas_canon_applies", "family_ideas", + "status <> 'canon' OR length(btrim(coalesce(applies_when, ''))) > 0", + ) + op.create_check_constraint( + "ck_family_ideas_canon_version", "family_ideas", "canon_version >= 1", + ) + op.create_index( + "uq_family_ideas_topic", "family_ideas", ["topic_id"], + unique=True, postgresql_where=sa.text("topic_id IS NOT NULL"), + ) + op.create_index("ix_family_ideas_status", "family_ideas", ["status"]) + + op.create_table( + "family_idea_platforms", + sa.Column("note_id", sa.Integer(), + sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True), + sa.Column("platform_id", sa.Integer(), + sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_index("ix_family_idea_platforms_platform_id", "family_idea_platforms", ["platform_id"]) + + op.create_table( + "family_idea_references", + sa.Column("idea_id", sa.Integer(), + sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True), + sa.Column("snippet_id", sa.Integer(), + sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_index("ix_family_idea_references_snippet_id", "family_idea_references", ["snippet_id"]) + + op.create_table( + "family_adoptions", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("project_id", sa.Integer(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("idea_id", sa.Integer(), + sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False), + sa.Column("status", sa.Text(), nullable=False, server_default="unassessed"), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("canon_version", sa.Integer(), nullable=True), + sa.Column("assessed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("decided_via", sa.Text(), nullable=True), + sa.Column("owed_task_id", sa.Integer(), + sa.ForeignKey("notes.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_check_constraint( + "ck_family_adoptions_status", "family_adoptions", _in("status", _ADOPTION_STATUSES), + ) + # A departure is only a record if it says why. + op.create_check_constraint( + "ck_family_adoptions_reason", "family_adoptions", + "NOT (" + _in("status", _REASONED) + ") " + "OR length(btrim(coalesce(reason, ''))) > 0", + ) + op.create_check_constraint( + "ck_family_adoptions_decided_via", "family_adoptions", + "decided_via IS NULL OR " + _in("decided_via", _DECIDERS), + ) + op.create_index( + "uq_family_adoptions_pair", "family_adoptions", ["project_id", "idea_id"], unique=True, + ) + op.create_index("ix_family_adoptions_project_id", "family_adoptions", ["project_id"]) + op.create_index("ix_family_adoptions_idea_id", "family_adoptions", ["idea_id"]) + + op.create_table( + "family_decisions", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("idea_id", sa.Integer(), + sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False), + sa.Column("project_id", sa.Integer(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("before", postgresql.JSONB(), nullable=True), + sa.Column("after", postgresql.JSONB(), nullable=True), + sa.Column("evidence", postgresql.JSONB(), nullable=True), + sa.Column("precedent_ids", postgresql.JSONB(), nullable=False, + server_default=sa.text("'[]'::jsonb")), + sa.Column("decided_via", sa.Text(), nullable=False, server_default="agent"), + sa.Column("user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_check_constraint( + "ck_family_decisions_action", "family_decisions", _in("action", _ACTIONS), + ) + op.create_check_constraint( + "ck_family_decisions_decided_via", "family_decisions", _in("decided_via", _DECIDERS), + ) + # The reason IS the record — the agent decides with no approval step, so + # a decision that cannot say why is not one anybody can review or follow. + op.create_check_constraint( + "ck_family_decisions_reason", "family_decisions", "length(btrim(reason)) > 0", + ) + op.create_index("ix_family_decisions_idea_id", "family_decisions", ["idea_id"]) + op.create_index("ix_family_decisions_project_id", "family_decisions", ["project_id"]) + + +def downgrade() -> None: + op.drop_index("ix_family_decisions_project_id", table_name="family_decisions") + op.drop_index("ix_family_decisions_idea_id", table_name="family_decisions") + op.drop_table("family_decisions") + + op.drop_index("ix_family_adoptions_idea_id", table_name="family_adoptions") + op.drop_index("ix_family_adoptions_project_id", table_name="family_adoptions") + op.drop_index("uq_family_adoptions_pair", table_name="family_adoptions") + op.drop_table("family_adoptions") + + op.drop_index("ix_family_idea_references_snippet_id", table_name="family_idea_references") + op.drop_table("family_idea_references") + + op.drop_index("ix_family_idea_platforms_platform_id", table_name="family_idea_platforms") + op.drop_table("family_idea_platforms") + + op.drop_index("ix_family_ideas_status", table_name="family_ideas") + op.drop_index("uq_family_ideas_topic", table_name="family_ideas") + op.drop_table("family_ideas") + + op.drop_index("ix_project_platforms_platform_id", table_name="project_platforms") + op.drop_table("project_platforms") + + op.drop_index("uq_platforms_slug", table_name="platforms") + op.drop_table("platforms") diff --git a/frontend/src/api/family.ts b/frontend/src/api/family.ts new file mode 100644 index 00000000..5162b578 --- /dev/null +++ b/frontend/src/api/family.ts @@ -0,0 +1,158 @@ +/** + * Family canon (milestone 463): ideas every project on a platform shares, and + * the log of every decision about them. + * + * The agent promotes by written criteria — no approval step. This door is for + * reading what was decided and why, and for retiring an idea or undoing a + * decision when a person disagrees. + */ +import { apiGet, apiPost } from "@/api/client"; + +export type IdeaStatus = "candidate" | "canon" | "retired"; +export type DecisionAction = "propose" | "promote" | "revise" | "retire" | "assess" | "undo"; + +export interface Criterion { + key: string; + title: string; + test: string; +} + +export interface FamilyIdea { + note_id: number; + status: IdeaStatus; + applies_when: string; + canon_version: number; + topic_id: number | null; + title: string; + note_type: string; + is_task: boolean; + project_id: number | null; + platforms: string[]; + created_at: string | null; + updated_at: string | null; +} + +/** A state snapshot a decision records — slugs, never ids. */ +export interface IdeaSnapshot { + status: IdeaStatus; + applies_when: string; + canon_version: number; + platforms: string[]; +} + +export type AdoptionStatus = "unassessed" | "adopted" | "variant" | "exempt" | "owed"; + +/** One project's answer as a decision records it. */ +export interface AdoptionSnapshot { + status: AdoptionStatus; + reason: string; + canon_version: number | null; +} + +/** + * A decision about the idea itself (project_id null) snapshots the idea; a + * project's assessment (project_id set) snapshots that project's answer. + */ +export interface FamilyDecision { + id: number; + idea_id: number; + idea_title?: string; + project_id: number | null; + project_title?: string | null; + action: DecisionAction; + reason: string; + before: IdeaSnapshot | AdoptionSnapshot | null; + after: IdeaSnapshot | AdoptionSnapshot | null; + evidence: Record; + precedent_ids: number[]; + decided_via: "agent" | "operator" | "system"; + user_id: number | null; + created_at: string | null; + undoable?: boolean; +} + +/** One ground of the conflict order, in order. */ +export interface ConflictGround { + key: string; + title: string; + test: string; + fold_as: "alternative" | "trap" | "condition"; +} + +export async function listFamilyIdeas( + status = "", +): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[]; conflict_order?: ConflictGround[] }> { + const q = status ? `?status=${encodeURIComponent(status)}` : ""; + return apiGet(`/api/family/ideas${q}`); +} + +export async function listFamilyDecisions(limit = 50, offset = 0): Promise { + const data = await apiGet<{ decisions: FamilyDecision[] }>( + `/api/family/decisions?limit=${limit}&offset=${offset}`, + ); + return data.decisions; +} + +export async function undoFamilyDecision(id: number, reason: string) { + return apiPost<{ decision: FamilyDecision }>(`/api/family/decisions/${id}/undo`, { reason }); +} + +export async function retireFamilyIdea(noteId: number, reason: string) { + return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason }); +} + +export interface Outcome { + key: AdoptionStatus; + title: string; + test: string; +} + +/** One project's answer to one canon idea — a cell of the matrix. */ +export interface AdoptionCell { + project_id: number; + project_title: string; + idea_id: number; + idea_title: string; + idea_version: number; + /** The project is on one of the idea's platforms. */ + in_scope: boolean; + /** A ledger row exists. False: the idea reaches the project, nobody asked yet. */ + reached: boolean; + status: AdoptionStatus; + reason: string; + canon_version: number | null; + assessed_at: string | null; + decided_via: "agent" | "operator" | "system" | null; + /** Answered against a canon version that is not the current one. */ + needs_recheck: boolean; + owed_task: { id: number; title: string; status: string } | null; + /** Live shapes in the project classified against one of the idea's references. */ + shapes: { path: string; symbol: string; status: "canonical" | "instance" | "variant" }[]; +} + +export interface MatrixIdea { + note_id: number; + title: string; + note_type: string; + is_task: boolean; + canon_version: number; + applies_when: string; + platforms: string[]; +} + +export interface AdoptionMatrix { + ideas: MatrixIdea[]; + projects: { id: number; title: string; platforms: string[] }[]; + cells: AdoptionCell[]; + outcomes: Outcome[]; +} + +export async function fetchAdoptionMatrix( + opts: { platform?: string; projectId?: number } = {}, +): Promise { + const q = new URLSearchParams(); + if (opts.platform) q.set("platform", opts.platform); + if (opts.projectId) q.set("project_id", String(opts.projectId)); + const qs = q.toString(); + return apiGet(`/api/family/matrix${qs ? `?${qs}` : ""}`); +} diff --git a/frontend/src/api/inception.ts b/frontend/src/api/inception.ts index ece42631..f520cc21 100644 --- a/frontend/src/api/inception.ts +++ b/frontend/src/api/inception.ts @@ -1,9 +1,14 @@ /** Project inception (milestone 297): what a project was decided to inherit. */ import { apiGet, apiPost } from "@/api/client"; +import type { ProjectPlatform } from "@/api/platforms"; export interface InceptionChoices { design_system_id: number | null; seed_systems: boolean; + /** Platform slugs (milestone 463). null = not answered; a list is the + * whole answer — members left out of it are recorded as "not this". + * Absent on records decided before the question existed. */ + platforms?: string[] | null; } export interface InceptionRecord { @@ -17,16 +22,24 @@ export interface InceptionDefaults { design_system_id: number | null; design_systems: { id: number; title: string }[]; systems: number; + platforms: { slug: string; name: string }[]; + /** What detection (or an earlier answer) already says about the project. */ + project_platforms: ProjectPlatform[]; } export interface InceptionDecision { project_id: number; inception: InceptionRecord; - effects: { design_system_id: number | null; systems_seeded: string[] }; + effects: { + design_system_id: number | null; + systems_seeded: string[]; + /** The project's answers after the decision; null when left unstated. */ + platforms: ProjectPlatform[] | null; + }; } export const emptyChoices = (): InceptionChoices => ({ - design_system_id: null, seed_systems: false, + design_system_id: null, seed_systems: false, platforms: null, }); export const fetchInceptionDefaults = (projectId: number) => diff --git a/frontend/src/api/platforms.ts b/frontend/src/api/platforms.ts new file mode 100644 index 00000000..8e27cd58 --- /dev/null +++ b/frontend/src/api/platforms.ts @@ -0,0 +1,77 @@ +/** + * Platforms — what a project is built on or ships as (milestone 463). + * + * The catalog is GLOBAL, like the canonical areas, and admin-written. A + * project's answer per platform is `declared` (a person said so), `detected` + * (a marker file in a bound repo said so) or `rejected` (a person said no — + * kept, so the next refresh cannot detect it back). Only declared and + * detected make a project a member, and membership is what decides which + * family ideas reach it. + */ +import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client"; + +export interface Platform { + id: number; + name: string; + /** The match key, and the name every door takes (stable across restores). */ + slug: string; + description: string | null; + /** Repo-relative globs. A bare name matches a basename anywhere; one with + * a slash matches the whole path. Empty = declare-only. */ + markers: string[]; + order_index: number; + created_at: string | null; + updated_at: string | null; +} + +export type PlatformState = "declared" | "detected" | "rejected"; +/** What a person may set. `null` withdraws the answer. */ +export type SettablePlatformState = "declared" | "rejected" | null; + +export interface ProjectPlatform { + id: number; + slug: string; + name: string; + state: PlatformState; +} + +export const MEMBER_STATES: PlatformState[] = ["declared", "detected"]; + +export async function listPlatforms(): Promise { + const data = await apiGet<{ platforms: Platform[] }>("/api/platforms"); + return data.platforms; +} + +/** Admin only. A name that reduces to an existing slug answers 409. */ +export async function createPlatform(data: { + name: string; + description?: string; + markers?: string[]; +}): Promise { + return apiPost("/api/platforms", data); +} + +export async function updatePlatform( + id: number, + data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>, +): Promise { + return apiPatch(`/api/platforms/${id}`, data); +} + +export async function fetchProjectPlatforms(projectId: number): Promise { + const data = await apiGet<{ project_platforms: ProjectPlatform[] }>( + `/api/projects/${projectId}/platforms`, + ); + return data.project_platforms; +} + +/** Only the slugs named change; the update applies whole or not at all. */ +export async function setProjectPlatforms( + projectId: number, + platforms: Record, +): Promise { + const data = await apiPut<{ project_platforms: ProjectPlatform[] }>( + `/api/projects/${projectId}/platforms`, { platforms }, + ); + return data.project_platforms; +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 0363f469..dfce0208 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -50,6 +50,7 @@ router.afterEach(() => { Projects Snippets Rulebooks + Family + - - diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 999f9dbe..35d17f63 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -128,6 +128,13 @@ const router = createRouter({ name: "rules", component: () => import("@/views/RulesView.vue"), }, + { + // Family canon (milestone 463): ideas shared across every project on a + // platform, and the log of every decision about them. + path: "/family", + name: "family", + component: () => import("@/views/FamilyView.vue"), + }, { // The design systems this install RECORDS — for the projects it tracks, // not for the install itself. There was a sibling `/design` that read the diff --git a/frontend/src/stores/platforms.ts b/frontend/src/stores/platforms.ts new file mode 100644 index 00000000..531427f3 --- /dev/null +++ b/frontend/src/stores/platforms.ts @@ -0,0 +1,48 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; +import * as api from "@/api/platforms"; +import type { Platform } from "@/api/platforms"; + +/** + * The global platform catalog (milestone 463). Shared by every project, so + * it is fetched once per session — as the canonical-area catalog is. + */ +export const usePlatformsStore = defineStore("platforms", () => { + const catalog = ref([]); + const loaded = ref(false); + const loading = ref(false); + + async function fetchCatalog(force = false) { + if (loaded.value && !force) return catalog.value; + loading.value = true; + try { + catalog.value = await api.listPlatforms(); + loaded.value = true; + } catch { + // The catalog is a vocabulary for a form; an empty one degrades the + // form rather than failing the screen it sits on. + catalog.value = []; + } finally { + loading.value = false; + } + return catalog.value; + } + + async function createEntry(data: { name: string; description?: string; markers?: string[] }) { + const entry = await api.createPlatform(data); + catalog.value.push(entry); + return entry; + } + + async function updateEntry( + id: number, + data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>, + ) { + const entry = await api.updatePlatform(id, data); + const idx = catalog.value.findIndex((p) => p.id === id); + if (idx >= 0) catalog.value[idx] = entry; + return entry; + } + + return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry }; +}); diff --git a/frontend/src/utils/recordHref.ts b/frontend/src/utils/recordHref.ts new file mode 100644 index 00000000..7fd71ecd --- /dev/null +++ b/frontend/src/utils/recordHref.ts @@ -0,0 +1,9 @@ +/** Where a record opens. A task, a snippet, a lesson and a note live at + * different routes, and a link that guesses wrong is worse than one that is + * plain. One copy, for every list that links a record of any kind. */ +export function recordHref(rec: { id: number; is_task?: boolean; note_type?: string }): 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}`; +} diff --git a/frontend/src/views/FamilyView.vue b/frontend/src/views/FamilyView.vue new file mode 100644 index 00000000..67444c6e --- /dev/null +++ b/frontend/src/views/FamilyView.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/frontend/src/views/LessonDetailView.vue b/frontend/src/views/LessonDetailView.vue index f9a159c4..94d17389 100644 --- a/frontend/src/views/LessonDetailView.vue +++ b/frontend/src/views/LessonDetailView.vue @@ -41,6 +41,7 @@ import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight"; import { useToastStore } from "@/stores/toast"; import { renderMarkdown } from "@/utils/markdown"; import { canWriteRecord } from "@/utils/permission"; +import { recordHref } from "@/utils/recordHref"; const route = useRoute(); const router = useRouter(); @@ -59,14 +60,6 @@ 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}`; -} const canWrite = computed(() => canWriteRecord(lesson.value?.permission)); @@ -193,7 +186,7 @@ onMounted(load);

Learned from

  • - + {{ rec.title }} {{ rec.status }} @@ -313,6 +306,11 @@ onMounted(load); + + - - - - - -\n\n