diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0d25232..696ced4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1387,12 +1387,14 @@ app.get('/wiki/tree', async (c) => { template: string; topic: string | null; reuse_count: number; + author_user_id: string | null; }>( `SELECT n.path, p.id AS prompt_id, p.template, p.topic, - p.reuse_count + p.reuse_count, + p.author_user_id FROM prompts p JOIN nodes n ON n.id = p.node_id WHERE n.team_token = $1 @@ -1448,6 +1450,7 @@ app.get('/wiki/tree', async (c) => { template: r.template, topic: r.topic, reuse_count: r.reuse_count, + author_user_id: r.author_user_id, }); } diff --git a/apps/dashboard/src/app/onboarding/page.tsx b/apps/dashboard/src/app/onboarding/page.tsx new file mode 100644 index 0000000..86179cf --- /dev/null +++ b/apps/dashboard/src/app/onboarding/page.tsx @@ -0,0 +1,38 @@ +// /onboarding — new-teammate landing. Surfaces the team's most-appreciated +// graduated prompts and durable patterns, sourced from /wiki/tree (no new +// API endpoint needed). Mirrors the team/skill-arc/wiki page shell. + +import Link from 'next/link'; +import { OnboardingView } from '@/components/onboarding-view'; +import { DEFAULT_TEAM_TOKEN } from '@/lib/api'; + +export default function OnboardingPage({ + searchParams, +}: { + searchParams: { team?: string }; +}) { + const token = searchParams.team ?? DEFAULT_TEAM_TOKEN; + + return ( +
+ + ← Teams + +
+

Onboarding

+

+ New to the team? Start here. The prompts the team reuses most and the + patterns that have stuck — distilled from real usage, no docs to read. +

+

+ token: {token} +

+
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 2892d61..3711171 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -100,10 +100,10 @@ export default async function HomePage() { Team's knowledge - Behavioral metrics + Onboarding diff --git a/apps/dashboard/src/components/onboarding-view.tsx b/apps/dashboard/src/components/onboarding-view.tsx new file mode 100644 index 0000000..a01ac05 --- /dev/null +++ b/apps/dashboard/src/components/onboarding-view.tsx @@ -0,0 +1,317 @@ +'use client'; + +// Onboarding view — single-fetch over /wiki/tree, then flattened/sorted client +// side. Shows ALL graduated prompts and ALL learnings (durable + draft) using +// the exact same card/row format as the Team's-knowledge tree DetailPanel, so +// a new teammate sees a flat catalog without needing to click around the tree. + +import { useMemo } from 'react'; +import useSWR from 'swr'; +import type { + WikiTreeNode, + WikiTreePrompt, + WikiTreeResponse, +} from '@trailhead/shared'; +import { api } from '@/lib/api'; + +const REFRESH_MS = 30_000; + +type FlatPrompt = WikiTreePrompt & { node_path: string }; +type FlatLearning = { + id: string; + body: string; + reinforcement_count: number; + status: 'durable' | 'draft'; + node_path: string; +}; + +function flatten(nodes: WikiTreeNode[]): { + prompts: FlatPrompt[]; + learnings: FlatLearning[]; + durableCount: number; + draftCount: number; + folderCount: number; +} { + const prompts: FlatPrompt[] = []; + const learnings: FlatLearning[] = []; + let durableCount = 0; + let draftCount = 0; + for (const n of nodes) { + for (const p of n.graduated_prompts) prompts.push({ ...p, node_path: n.path }); + for (const l of n.durable_learnings) { + learnings.push({ + id: l.id, + body: l.body, + reinforcement_count: l.reinforcement_count, + status: 'durable', + node_path: n.path, + }); + durableCount += 1; + } + for (const l of n.draft_learnings) { + learnings.push({ + id: l.id, + body: l.body, + reinforcement_count: l.reinforcement_count, + status: 'draft', + node_path: n.path, + }); + draftCount += 1; + } + } + prompts.sort((a, b) => b.reuse_count - a.reuse_count); + // Durables first, then drafts; within each, sort by reinforcement count desc. + learnings.sort((a, b) => { + if (a.status !== b.status) return a.status === 'durable' ? -1 : 1; + return b.reinforcement_count - a.reinforcement_count; + }); + return { + prompts, + learnings, + durableCount, + draftCount, + folderCount: nodes.length, + }; +} + +function copyToClipboard(text: string): void { + void navigator.clipboard?.writeText(text); +} + +function formatAuthor(author: string | null | undefined): string { + if (!author) return 'anonymous'; + if (author.includes('@')) return author; + if (author.startsWith('user_')) return author.slice(5); + if (author.length > 18) return `${author.slice(0, 16)}…`; + return author; +} + +export function OnboardingView({ token }: { token: string }) { + const { data, error, isLoading } = useSWR( + ['wiki-tree', token], + () => api.wikiTree(token), + { refreshInterval: REFRESH_MS, revalidateOnFocus: true }, + ); + + const flat = useMemo(() => (data ? flatten(data.nodes) : null), [data]); + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (error || !data || !flat) { + return ( +
+ Failed to load onboarding view: {String((error as Error)?.message ?? 'unknown')} +
+ ); + } + + return ( +
+ + +
+ + {flat.prompts.length === 0 ? ( + + Graduated prompts appear here once a prompt is reused enough times + to be promoted by the API. + + ) : ( +
+ {flat.prompts.map((p, idx) => ( + + ))} +
+ )} +
+ +
+ + {flat.learnings.length === 0 ? ( + + Learnings appear here as the team uses{' '} + wiki_save. Drafts are auto-promoted + to durable after 3 reinforcements. + + ) : ( +
    + {flat.learnings.map((l) => ( + + ))} +
+ )} +
+
+ ); +} + +function SummaryStrip({ + prompts, + durables, + drafts, + folders, +}: { + prompts: number; + durables: number; + drafts: number; + folders: number; +}) { + const items = [ + { label: 'Graduated prompts', value: prompts }, + { label: 'Durable patterns', value: durables }, + { label: 'Draft learnings', value: drafts }, + { label: 'Folders covered', value: folders }, + ]; + return ( +
+ {items.map((it) => ( +
+
+ {it.label} +
+
+ {it.value.toLocaleString()} +
+
+ ))} +
+ ); +} + +function SectionHeader({ + eyebrow, + subtitle, +}: { + eyebrow: string; + subtitle: string; +}) { + return ( +
+

+ {eyebrow} +

+

+ {subtitle} +

+
+ ); +} + +function EmptyHint({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// Same visual format as wiki-tree.tsx DetailPanel's prompt block: +// best chip (top only) + path + topic + reuse + author + copy + pre. +function PromptCard({ prompt, isTop }: { prompt: FlatPrompt; isTop: boolean }) { + return ( +
+
+
+ {isTop && ( + + best + + )} + + {prompt.node_path || '/'} + + {prompt.topic && ( + + {prompt.topic} + + )} + + {prompt.reuse_count}× reused + + + ✍ {formatAuthor(prompt.author_user_id)} + +
+ +
+
+        {prompt.template}
+      
+
+ ); +} + +// Same visual format as wiki-tree.tsx DetailPanel's learning row: +// status pill + path + reinforcement count + body. +function LearningRow({ learning }: { learning: FlatLearning }) { + const isDurable = learning.status === 'durable'; + return ( +
  • + +
    +
    + + {learning.status} + + + {learning.node_path || '/'} + + + {learning.reinforcement_count}× reinforced + +
    +

    + {learning.body} +

    +
    +
  • + ); +} diff --git a/apps/dashboard/src/components/wiki-tree.tsx b/apps/dashboard/src/components/wiki-tree.tsx index 3f2b8f4..0e0e9ea 100644 --- a/apps/dashboard/src/components/wiki-tree.tsx +++ b/apps/dashboard/src/components/wiki-tree.tsx @@ -409,6 +409,17 @@ function Tree2D({ ); } +// Render-friendly author label. Demo data has tokens like 'user_ana', +// 'user_marco' or null for legacy rows; show emails verbatim, take the +// suffix after the last underscore for tokens, fall back to anonymous. +function formatAuthor(author: string | null | undefined): string { + if (!author) return 'anonymous'; + if (author.includes('@')) return author; + if (author.startsWith('user_')) return author.slice(5); + if (author.length > 18) return `${author.slice(0, 16)}…`; + return author; +} + function ancestorChain(tree: Tree, selectedPath: string): WikiTreeNode[] { const paths: string[] = []; let cur = selectedPath; @@ -514,146 +525,158 @@ function DetailPanel({
    - {bodyChain.length > 0 && ( -
    -
    - General info + {/* ── Section 1 — Onboarding Info ─────────────────────────── */} +
    +
    +

    + Onboarding info +

    +

    + Team prompting patterns and best prompts for this scope. +

    +
    + {prompts.length === 0 ? ( +
    + No graduated prompts here yet. Patterns appear once the team reuses + a prompt enough for it to be promoted.
    + ) : (
    - {bodyChain.map((n) => { - const color = layerColorFor(n.path); + {prompts.map((p, idx) => { + const isTop = idx === 0; return ( -
    - - {n.path || '/'} - -
    -                    {n.body_md}
    +                
    +
    +
    + {isTop && ( + + best + + )} + + {p.path || '/'} + + {p.topic && ( + + {p.topic} + + )} + + {p.reuse_count}× reused + + + ✍ {formatAuthor(p.author_user_id)} + +
    + +
    +
    +                    {p.template}
                       
    ); })}
    -
    - )} + )} +
    - {durables.length > 0 && ( -
    -
    - Durable learnings + {/* ── Section 2 — Learnings (durable + draft) ──────────────── */} +
    +
    +

    + Learnings +

    +

    + Durable = reinforced 3+ times. Draft = awaiting reinforcement. +

    +
    + {durables.length === 0 && drafts.length === 0 ? ( +
    + No learnings here yet. Every wiki_save{' '} + from the team starts as a draft and gets promoted after 3 hits.
    + ) : (
      - {durables.map((l) => { - const color = layerColorFor(l.path); - return ( -
    • - - + {[...durables.map((l) => ({ ...l, status: 'durable' as const })), + ...drafts.map((l) => ({ ...l, status: 'draft' as const }))] + .map((l) => { + const isDurable = l.status === 'durable'; + return ( +
    • - {l.path || '/'} - {' '} - - ({l.reinforcement_count}×) - {' '} - {l.body} - -
    • - ); - })} + className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${isDurable ? 'bg-emerald-400' : 'bg-muted-foreground/40'}`} + /> +
      +
      + + {l.status} + + + {l.path || '/'} + + + {l.reinforcement_count}× reinforced + +
      +

      + {l.body} +

      +
      + + ); + })}
    -
    - )} + )} +
    - {drafts.length > 0 && ( -
    -
    - Draft learnings -
    -
      - {drafts.map((l) => { - const color = layerColorFor(l.path); - return ( -
    • - - - - {l.path || '/'} - {' '} - ({l.reinforcement_count}×){' '} - {l.body} - -
    • - ); - })} -
    + {/* ── Section 3 — Description of project (root → current) ──── */} +
    +
    +

    + Description of project +

    +

    + Cumulative narrative from root down to this folder. +

    - )} - - {prompts.length > 0 && ( -
    -
    - Graduated prompts + {bodyChain.length === 0 ? ( +
    + No descriptions written yet for this scope. Run{' '} + wiki_bootstrap to generate them.
    -
      - {prompts.map((p) => { - const color = layerColorFor(p.path); - return ( -
    • -
      - - {p.path || '/'} - - {p.topic && ( - - {p.topic} - - )} - - {p.reuse_count}× reused - -
      -
      -                    {p.template}
      -                  
      -
    • - ); - })} -
    -
    - )} + ) : ( +
    + {bodyChain.map((n) => ( +
    + + {n.path || '/'} + +
    +                  {n.body_md}
    +                
    +
    + ))} +
    + )} +
    ); } diff --git a/packages/shared/types.ts b/packages/shared/types.ts index e99c138..ff431e7 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -128,6 +128,7 @@ export interface WikiTreePrompt { template: string; topic: string | null; reuse_count: number; + author_user_id: string | null; } export interface WikiTreeNode { path: string;