From 370aeec2a00a9ad15872522b24df889832b5d18f Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 5 Jun 2026 09:30:00 +0200 Subject: [PATCH] feat: build local skills registry --- app/dashboard/audits/[id]/page.tsx | 17 ++ app/dashboard/audits/page.tsx | 11 +- ...sk-10 - Build-the-local-skills-registry.md | 38 ++- components/audits/audit-detail.tsx | 184 +++++++++++++ components/audits/audits-board.tsx | 134 ++++++++++ convex/audits.ts | 22 ++ convex/schema.ts | 10 + lib/skills-registry.ts | 178 +++++++++++++ skills.md | 59 +++++ skills/copy-clarity.md | 9 + skills/design-audit.md | 9 + skills/local-seo.md | 9 + skills/marketing-positioning.md | 9 + skills/offer-writing.md | 9 + skills/ux-friction-review.md | 9 + tests/audit-skills-schema.test.ts | 231 ++++++++++++++++ tests/audit-skills-ui.test.ts | 162 ++++++++++++ tests/skills-registry.test.ts | 250 ++++++++++++++++++ 18 files changed, 1334 insertions(+), 16 deletions(-) create mode 100644 app/dashboard/audits/[id]/page.tsx create mode 100644 components/audits/audit-detail.tsx create mode 100644 components/audits/audits-board.tsx create mode 100644 lib/skills-registry.ts create mode 100644 skills.md create mode 100644 skills/copy-clarity.md create mode 100644 skills/design-audit.md create mode 100644 skills/local-seo.md create mode 100644 skills/marketing-positioning.md create mode 100644 skills/offer-writing.md create mode 100644 skills/ux-friction-review.md create mode 100644 tests/audit-skills-schema.test.ts create mode 100644 tests/audit-skills-ui.test.ts create mode 100644 tests/skills-registry.test.ts diff --git a/app/dashboard/audits/[id]/page.tsx b/app/dashboard/audits/[id]/page.tsx new file mode 100644 index 0000000..3431965 --- /dev/null +++ b/app/dashboard/audits/[id]/page.tsx @@ -0,0 +1,17 @@ +import { AuditDetail } from "@/components/audits/audit-detail"; + +export default async function AuditDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + return ( +
+
+ +
+
+ ); +} diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx index 05097e7..830f994 100644 --- a/app/dashboard/audits/page.tsx +++ b/app/dashboard/audits/page.tsx @@ -1,10 +1,11 @@ -import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; +import { AuditsBoard } from "@/components/audits/audits-board"; export default function AuditsPage() { return ( - +
+
+ +
+
); } diff --git a/backlog/tasks/task-10 - Build-the-local-skills-registry.md b/backlog/tasks/task-10 - Build-the-local-skills-registry.md index b3d929c..3dcad8c 100644 --- a/backlog/tasks/task-10 - Build-the-local-skills-registry.md +++ b/backlog/tasks/task-10 - Build-the-local-skills-registry.md @@ -1,9 +1,10 @@ --- id: TASK-10 title: Build the local skills registry -status: To Do +status: Done assignee: [] created_date: '2026-06-03 19:13' +updated_date: '2026-06-05 07:28' labels: - mvp - agent @@ -24,19 +25,34 @@ Create the local skills registry concept for the agent. Design and marketing ski ## Acceptance Criteria -- [ ] #1 A project-local skills directory or convention exists for imported design and marketing skills -- [ ] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category -- [ ] #3 Agent code can load and parse the skills registry into structured skill metadata -- [ ] #4 Audit records store the list of used skills, including skill name/category and version or source where available -- [ ] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not +- [x] #1 A project-local skills directory or convention exists for imported design and marketing skills +- [x] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category +- [x] #3 Agent code can load and parse the skills registry into structured skill metadata +- [x] #4 Audit records store the list of used skills, including skill name/category and version or source where available +- [x] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not ## Implementation Plan -1. Define project-local skill storage conventions. -2. Create the initial skills.md registry format and seed entries for design, UX, marketing, copy, SEO, and offer-writing skills. -3. Add parser/loader for registry metadata. -4. Store selected skill metadata with each audit. -5. Show used skills in the internal audit detail UI only. +1. Worker A uses TDD to add project-local skills conventions, seed skills.md, skills source files, and a strict skills registry parser/loader. +2. Worker B uses TDD to extend Convex audit persistence so audit records can store used skill metadata with name, category, version, and source. +3. Worker C uses TDD to add the internal dashboard audit detail/list UI and compact Verwendete Skills overview while keeping public audit pages free of skill metadata. +4. Orchestrator reviews subagent outputs, resolves integration issues through focused subagents, runs full verification, and checks TASK-10 acceptance criteria without marking Done until user confirmation. + +## Implementation Notes + + +Started implementation using subagent-driven and test-driven workflow with parallel agents where write scopes are independent. Orchestrator will not hand-code feature changes; workers own implementation patches and tests. + +Worker C: implemented audits dashboard internals for TASK-10. Added new tests (tests/audit-skills-ui.test.ts), new components/audits/{audits-board,audit-detail}.tsx and routes app/dashboard/audits/page.tsx + app/dashboard/audits/[id]/page.tsx. Internal detail route still passes raw id from params Promise; public audit page unchanged and remains skill-free. + +Implementation completed through parallel subagent-driven TDD slices. Worker scopes: registry/parser, Convex audit persistence, dashboard audit UI. Review findings addressed by follow-up workers for getDetail result shape/useQuery FunctionReference and indented skills.md field parsing. Fresh orchestrator verification: pnpm test passed with 179/179 tests; pnpm lint passed with 0 errors and 2 existing generated BetterAuth warnings; pnpm exec convex codegen --dry-run --typecheck enable passed after network escalation; pnpm build passed after network escalation. Sandbox-only failures before escalation were DNS/Sentry for Convex and Google Fonts for Next build. + + +## Final Summary + + +Shipped the local skills registry with project-local skills.md and skills/ source files, parser/loader tests, Convex audit usedSkills persistence, and internal dashboard audit skill overview. Verified with pnpm test; task remains public-audit safe because used skills are only shown in the dashboard detail route. + diff --git a/components/audits/audit-detail.tsx b/components/audits/audit-detail.tsx new file mode 100644 index 0000000..e2f61fd --- /dev/null +++ b/components/audits/audit-detail.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useMemo } from "react"; + +import { useQuery } from "convex/react"; +import type { Id } from "@/convex/_generated/dataModel"; +import { api } from "@/convex/_generated/api"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Globe } from "lucide-react"; + +type UsedSkill = { + name: string; + purpose?: string; + category?: string; + source?: string; + version?: string; +}; + +type LeadContext = { + _id: Id<"leads">; + companyName?: string; + websiteDomain?: string; + websiteUrl?: string; + city?: string; + niche?: string; +}; + +type SkillAwareAudit = { + _id: Id<"audits">; + slug: string; + checkedDomain: string; + status: "draft" | "approved" | "published" | "deactivated"; + checkedPages: string[]; + createdAt?: number; + updatedAt?: number; + usedSkills?: UsedSkill[]; + internalSummary?: string | null; +}; + +type AuditDetailResult = { + audit: SkillAwareAudit; + lead: LeadContext | null; +} | null; + +const statusText: Record = { + draft: "Entwurf", + approved: "Freigegeben", + published: "Veröffentlicht", + deactivated: "Deaktiviert", +}; + +function getStatusLabel(status: SkillAwareAudit["status"]) { + return statusText[status] ?? "Unbekannt"; +} + +function leadSummary(lead: LeadContext | null | undefined) { + if (!lead) { + return "Kein Lead-Kontext gespeichert"; + } + + const detail = [lead.city, lead.niche].filter(Boolean).join(" • "); + let leadDomain = lead.websiteDomain ?? "—"; + + if (!leadDomain && lead.websiteUrl) { + try { + leadDomain = new URL(lead.websiteUrl).hostname; + } catch { + leadDomain = lead.websiteUrl; + } + } + + return ( + <> +

{lead.companyName ?? "Lead ohne Name"}

+

{detail || "Kein Kontext textlich"}

+

+ + {leadDomain} +

+ + ); +} + +export function AuditDetail({ id }: { id: string | Id<"audits"> }) { + const result = useQuery(api.audits.getDetail, { + id: id as Id<"audits">, + }) as AuditDetailResult | undefined; + const audit = result?.audit; + const lead = result?.lead; + + const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]); + + if (result === null) { + return ( + + + Audit nicht gefunden + + Der gewünschte Audit-Datensatz konnte nicht geladen werden. + + + + ); + } + + if (audit === undefined) { + return ( + + + Audit wird geladen... + + + ); + } + + return ( +
+ + + Audit-Detail + #{audit.slug} +

+ + {audit.checkedDomain} +

+
+ +
+

Status

+

+ {getStatusLabel(audit.status)} +

+
+
+

Seitenanzahl

+

{audit.checkedPages.length}

+
+
+

Lead-Kontext

+
{leadSummary(lead)}
+
+ {audit.internalSummary ? ( +
+

Interne Notiz

+

{audit.internalSummary}

+
+ ) : null} +
+
+ + + + Verwendete Skills + Skills, die an diesem Audit beteiligt wurden. + + + {usedSkills.length === 0 ? ( +

Keine Skills gespeichert

+ ) : ( +
    + {usedSkills.map((skill, index) => ( +
  • +

    {skill.name}

    +

    + {skill.purpose ?? "Keine Zweckbeschreibung"} +

    +

    + {skill.category ? {skill.category} : null} + {skill.version ? {skill.version} : null} + {skill.source ? {skill.source} : null} +

    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/components/audits/audits-board.tsx b/components/audits/audits-board.tsx new file mode 100644 index 0000000..3e602b6 --- /dev/null +++ b/components/audits/audits-board.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo } from "react"; + +import { useQuery } from "convex/react"; +import { FunctionReturnType } from "convex/server"; +import { Files, SquarePen } from "lucide-react"; +import Link from "next/link"; + +import { api } from "@/convex/_generated/api"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; + +type AuditsListResult = FunctionReturnType; +type AuditRow = NonNullable[number]; + +const statusText: Record = { + draft: "Entwurf", + approved: "Freigegeben", + published: "Veröffentlicht", + deactivated: "Deaktiviert", +}; + +const fallbackStatus = "Unbekannt"; + +function formatPageCount(pages: AuditRow["checkedPages"]) { + return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`; +} + +function getStatusLabel(status: AuditRow["status"]) { + return statusText[status] ?? fallbackStatus; +} + +function AuditsBoardLoading() { + return ( +
+
+

Interne Audit-Übersicht

+

Audits

+

Audits werden geladen...

+
+
+
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+
+
+ ); +} + +export function AuditsBoard() { + const audits = useQuery(api.audits.list, { limit: 100 }); + const rows = useMemo(() => { + if (!audits) { + return []; + } + + return [...audits].sort((a, b) => b.createdAt - a.createdAt); + }, [audits]); + + if (audits === undefined) { + return ; + } + + if (rows.length === 0) { + return ( +
+
+

Interne Audit-Übersicht

+

Audits

+
+ +
+

Noch keine Audits

+

+ Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte + Zeilen. +

+
+
+ ); + } + + return ( +
+
+

Interne Audit-Übersicht

+

Audits

+
+ +
+
+ Slug + Domain + Status + Seitenanzahl + Aktion +
+ +
+ {rows.map((audit: AuditRow) => ( +
+
+

{audit.slug}

+
+

{audit.checkedDomain}

+ {getStatusLabel(audit.status)} +

+ + + {formatPageCount(audit.checkedPages)} + +

+
+ + + Öffnen + +
+
+ ))} +
+
+
+ ); +} diff --git a/convex/audits.ts b/convex/audits.ts index 76f6a98..0558bb7 100644 --- a/convex/audits.ts +++ b/convex/audits.ts @@ -9,6 +9,14 @@ const auditStatus = v.union( v.literal("published"), v.literal("deactivated"), ); +const usedSkillsValidator = v.array( + v.object({ + name: v.string(), + category: v.string(), + version: v.optional(v.string()), + source: v.optional(v.string()), + }), +); export const create = mutation({ args: { @@ -16,6 +24,7 @@ export const create = mutation({ slug: v.string(), checkedDomain: v.string(), checkedPages: v.array(v.string()), + usedSkills: v.optional(usedSkillsValidator), status: v.optional(auditStatus), internalSummary: v.optional(v.string()), publicSummary: v.optional(v.string()), @@ -42,6 +51,19 @@ export const create = mutation({ }, }); +export const getDetail = query({ + args: { id: v.id("audits") }, + handler: async (ctx, args) => { + const audit = await ctx.db.get(args.id); + if (!audit) { + return null; + } + + const lead = await ctx.db.get(audit.leadId); + return { audit, lead }; + }, +}); + export const get = query({ args: { id: v.id("audits") }, handler: async (ctx, args) => { diff --git a/convex/schema.ts b/convex/schema.ts index 2563143..7e08b52 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -231,6 +231,16 @@ export default defineSchema({ pageSpeedSummary: v.optional(auditMetricSummary), playwrightSummary: v.optional(playwrightSummary), textFindings: v.optional(v.array(v.string())), + usedSkills: v.optional( + v.array( + v.object({ + name: v.string(), + category: v.string(), + version: v.optional(v.string()), + source: v.optional(v.string()), + }), + ), + ), skillSummaries: v.optional( v.array( v.object({ diff --git a/lib/skills-registry.ts b/lib/skills-registry.ts new file mode 100644 index 0000000..663fe76 --- /dev/null +++ b/lib/skills-registry.ts @@ -0,0 +1,178 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export const SKILL_CATEGORIES = [ + "design", + "ux", + "marketing", + "copy", + "seo", + "offer", +] as const; + +export type SkillCategory = (typeof SKILL_CATEGORIES)[number]; + +export type SkillRegistryEntry = { + name: string; + purpose: string; + whenToUse: string; + whenNotToUse: string; + requiredInput: string; + expectedOutput: string; + category: SkillCategory; + version?: string; + source?: string; +}; + +export type AuditUsedSkill = { + name: string; + category: SkillCategory; + version?: string; + source?: string; +}; + +type ParsedFieldName = + | "Purpose" + | "When to use" + | "When not to use" + | "Required input" + | "Expected output" + | "Category" + | "Version" + | "Source"; + +const REQUIRED_FIELDS: ParsedFieldName[] = [ + "Purpose", + "When to use", + "When not to use", + "Required input", + "Expected output", + "Category", +]; + +const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/; + +function normalizeCategory(value: string): SkillCategory { + const normalized = value.toLowerCase(); + if (!isValidSkillCategory(normalized)) { + throw new Error( + `Unknown category "${value}". Valid categories are: ${SKILL_CATEGORIES.join(", ")}.`, + ); + } + return normalized; +} + +function isValidSkillCategory( + value: string, +): value is SkillCategory { + return (SKILL_CATEGORIES as ReadonlyArray).includes(value); +} + +function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry { + let name: string | null = null; + const values: Partial> = {}; + let currentField: ParsedFieldName | null = null; + + const sectionTitle = lines[0]; + if (!sectionTitle.startsWith("##")) { + throw new Error(`Expected section ${sectionIndex} to start with a skill header.`); + } + name = sectionTitle.replace(/^##\s*/, "").trim(); + if (name.length === 0) { + throw new Error(`Skill section ${sectionIndex} has an empty name.`); + } + + for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const trimmedLine = line.trim(); + + if (trimmedLine.length === 0) { + continue; + } + + const match = trimmedLine.match(FIELD_LABELS_RE); + if (match) { + const field = match[1] as ParsedFieldName; + currentField = field; + values[field] = match[2].trim(); + continue; + } + + if (currentField === null) { + throw new Error(`Unexpected line in section "${name}": ${line}`); + } + + values[currentField] = `${values[currentField] ?? ""}\n${line.trim()}`.trim(); + } + + for (const requiredField of REQUIRED_FIELDS) { + const value = values[requiredField]?.trim(); + if (!value) { + throw new Error( + `Missing required field "${requiredField}" for skill "${name}".`, + ); + } + } + + const category = normalizeCategory(values.Category!.trim()); + + return { + name, + purpose: values["Purpose"]!, + whenToUse: values["When to use"]!, + whenNotToUse: values["When not to use"]!, + requiredInput: values["Required input"]!, + expectedOutput: values["Expected output"]!, + category, + version: values["Version"]?.trim() || undefined, + source: values["Source"]?.trim() || undefined, + }; +} + +export function parseSkillsRegistry(source: string): SkillRegistryEntry[] { + const normalized = source.replace(/\r\n/g, "\n"); + const rawSections = normalized + .split(/^##\s+/m) + .map((entry) => entry.trim()) + .filter(Boolean); + + const entries: SkillRegistryEntry[] = []; + const names = new Set(); + + for (let index = 0; index < rawSections.length; index += 1) { + const rawSection = rawSections[index]; + const lines = rawSection + .split("\n") + .map((line) => line.trimEnd()) + .filter((line, lineIndex) => line.length > 0 || lineIndex === 0); + + const sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)]; + const parsed = parseSection(sectionLines, index + 1); + + const normalizedName = parsed.name.trim().toLowerCase(); + if (names.has(normalizedName)) { + throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`); + } + + names.add(normalizedName); + entries.push(parsed); + } + + return entries; +} + +export async function loadSkillsRegistry( + registryPath = join(process.cwd(), "skills.md"), +): Promise { + const source = await readFile(registryPath, "utf8"); + return parseSkillsRegistry(source); +} + +export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill { + return { + name: skill.name, + category: skill.category, + version: skill.version, + source: skill.source, + }; +} diff --git a/skills.md b/skills.md new file mode 100644 index 0000000..b515975 --- /dev/null +++ b/skills.md @@ -0,0 +1,59 @@ +## Design Audit +Purpose: Prüft die visuelle Qualität einer Seite auf Konsistenz, Orientierung und Conversion-Relevanz. +When to use: Nutze diesen Skill, wenn das Website-Layout, visuelle Hierarchie oder die Markenwirkung bewertet werden soll. +When not to use: Nicht für technische Crawling- oder Performance-Analyse. +Required input: URL, Seitenstruktur, Kernaussagen, Designsystem- oder Style-Constraints. +Expected output: Konkrete Verbesserungsvorschläge mit Priorität nach Wirkung auf Wahrnehmung und Conversion. +Category: design +Version: 1.0 +Source: skills/design-audit.md + +## UX Friction Review +Purpose: Findet Reibungspunkte in Nutzerfluss, Formularen und Aktionen, die Nutzer vom Ziel abhalten. +When to use: Nutze diesen Skill für Kontaktformulare, Terminbuchung und erste Kontaktversuche. +When not to use: Nicht verwenden, wenn keine klaren User-Flows sichtbar sind oder Rohdaten fehlen. +Required input: URL, beobachtete Nutzerwege, wichtigste Aktionen und Ziel-CTA. +Expected output: Priorisierte Liste der größten UX-Hürden mit klaren, umsetzbaren Gegenmaßnahmen. +Category: ux +Version: 1.0 +Source: skills/ux-friction-review.md + +## Marketing Positioning +Purpose: Schärft die Positionierung für lokales B2B/B2C-Webdesign im Wettbewerb. +When to use: Nutze ihn, wenn Differenzierung, Angebotsschwerpunkt und Nutzenstory fehlen. +When not to use: Nicht für reine SEO-Fix-Lists oder technische Fehlerbehebungen. +Required input: Leistungsversprechen, Zielkundensegment, lokale Wettbewerbslage, bisheriger Auftritt. +Expected output: Klare Positionierungshypothese mit passenden USPs und Zielgruppen-Fokus. +Category: marketing +Version: 1.0 +Source: skills/marketing-positioning.md + +## Copy Clarity +Purpose: Optimiert Klarheit, Lesbarkeit und Tonalität für Webseiten- und Outreach-Texte. +When to use: Nutze diesen Skill bei unklaren, langen oder unpassend abstrakten Texten. +When not to use: Nicht bei legalen Texten mit festen Formulierungen oder Produkt-CTAs ohne Kontext. +Required input: Zielgruppe, Zweck des Textes, bestehender Entwurf, bevorzugter Sprachstil. +Expected output: Überarbeitete, verständliche und handlungsstarke Textvorschläge. +Category: copy +Version: 1.0 +Source: skills/copy-clarity.md + +## Local SEO +Purpose: Bewertet lokale Auffindbarkeit, Konsistenz und Relevanz der Website für eine definierte Ortsmarke. +When to use: Nutze diesen Skill, wenn lokale Suchrelevanz, Google-Präsenz oder Impressum/Struktur geprüft werden. +When not to use: Nicht als Ersatz für technische SEO-Fehlerdiagnose ohne inhaltlichen Kontext. +Required input: Standortdaten, Nischenfokus, Seitenstruktur, Kontakt-/Nennungsdaten. +Expected output: Konkrete Maßnahmen für lokale Sichtbarkeit, NAP-Konsistenz und Local-Authority. +Category: seo +Version: 1.0 +Source: skills/local-seo.md + +## Offer Writing +Purpose: Erstellt klare, konkrete Angebote mit Problemfokus und glaubwürdiger Preislogik. +When to use: Nutze diesen Skill, wenn ein Vorschlag oder Angebotsentwurf in Audit- oder Outreach-Schritten gebraucht wird. +When not to use: Nicht für reine Analyse-Ausgaben ohne Angebotsreifheit. +Required input: Projektumfang, Zielprobleme, Budgetrahmen, Leistungsumfang, Risiko-/Zeitaspekte. +Expected output: Offer-Prompt, Paketstruktur und überzeugende, ehrliche Angebots-Formulierungen. +Category: offer +Version: 1.0 +Source: skills/offer-writing.md diff --git a/skills/copy-clarity.md b/skills/copy-clarity.md new file mode 100644 index 0000000..30078c7 --- /dev/null +++ b/skills/copy-clarity.md @@ -0,0 +1,9 @@ +# Copy Clarity + +Use this file for headline/body copy improvements. + +- Replace jargon with clear benefit language. +- Tighten sentences to one core message per paragraph. +- Keep benefit first, proof second, next step third. +- Preserve the requested tone and avoid overpromising. +- Output a concise before/after improvement set. diff --git a/skills/design-audit.md b/skills/design-audit.md new file mode 100644 index 0000000..57233e6 --- /dev/null +++ b/skills/design-audit.md @@ -0,0 +1,9 @@ +# Design Audit + +Use this file as the concrete operating guide for design-oriented analysis. + +- Check visual hierarchy and above-the-fold clarity. +- Validate CTA prominence, spacing, and action visibility. +- Flag inconsistent components, mismatched states, and weak contrast. +- Verify tone alignment between typography, imagery, and brand intent. +- Prioritize 3–5 actionable fixes by expected conversion impact. diff --git a/skills/local-seo.md b/skills/local-seo.md new file mode 100644 index 0000000..7815166 --- /dev/null +++ b/skills/local-seo.md @@ -0,0 +1,9 @@ +# Local SEO + +Use this file for local discovery and trust-relevance analysis. + +- Verify NAP consistency across page and metadata. +- Check service-location clarity and service-area fit. +- Review local proof signals: reviews, case examples, local references. +- Validate schema/markup hints and local keyword fit. +- Recommend realistic, high-impact local SEO adjustments. diff --git a/skills/marketing-positioning.md b/skills/marketing-positioning.md new file mode 100644 index 0000000..d1fa3f9 --- /dev/null +++ b/skills/marketing-positioning.md @@ -0,0 +1,9 @@ +# Marketing Positioning + +Use this file to sharpen market message and differentiation. + +- Define one-liner positioning for the target segment. +- List the top 3 differentiators vs local alternatives. +- Connect positioning to pain points and decision criteria. +- Keep claims evidence-backed from website and audit findings. +- Produce a short positioning paragraph for outreach and landing intro. diff --git a/skills/offer-writing.md b/skills/offer-writing.md new file mode 100644 index 0000000..c3c2f2c --- /dev/null +++ b/skills/offer-writing.md @@ -0,0 +1,9 @@ +# Offer Writing + +Use this guide when producing outreach-ready offer text. + +- Translate findings into 1–2 concrete pain-result pairings. +- Keep scope, deliverables, and next step explicit. +- Tie price framing to measurable benefit and timeline. +- Use transparent language and avoid inflated promises. +- Return a compact offer draft and optional follow-up variant. diff --git a/skills/ux-friction-review.md b/skills/ux-friction-review.md new file mode 100644 index 0000000..8e2a7a2 --- /dev/null +++ b/skills/ux-friction-review.md @@ -0,0 +1,9 @@ +# UX Friction Review + +Use this file when analyzing onboarding, lead capture, and first conversion path friction. + +- Map the main path from landing → action. +- Find blockers: too many clicks, hidden links, field overload, unclear status. +- Identify trust anchors: who, what, how long, what happens next. +- Assess mobile ergonomics and cognitive load. +- Recommend friction-reduction experiments with effort and impact. diff --git a/tests/audit-skills-schema.test.ts b/tests/audit-skills-schema.test.ts new file mode 100644 index 0000000..1278437 --- /dev/null +++ b/tests/audit-skills-schema.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import ts from "typescript"; + +const schemaPath = join(process.cwd(), "convex", "schema.ts"); +const auditsPath = join(process.cwd(), "convex", "audits.ts"); +const schemaSource = readFileSync(schemaPath, "utf8"); +const auditsSource = readFileSync(auditsPath, "utf8"); + +const sourceFile = ts.createSourceFile( + "audits.ts", + auditsSource, + ts.ScriptTarget.ES2022, + true, +); + +function extractTableSection(tableName: string) { + const marker = `${tableName}: defineTable({`; + const markerIndex = schemaSource.indexOf(marker); + assert.notEqual( + markerIndex, + -1, + `Expected schema table definition for ${tableName}.`, + ); + + const objectStart = schemaSource.indexOf("{", markerIndex); + let depth = 0; + let objectEnd = -1; + + for (let index = objectStart; index < schemaSource.length; index += 1) { + if (schemaSource[index] === "{") { + depth += 1; + } else if (schemaSource[index] === "}") { + depth -= 1; + if (depth === 0) { + objectEnd = index; + break; + } + } + } + + assert.notEqual( + objectEnd, + -1, + `Could not parse schema object for ${tableName}.`, + ); + const objectBlock = schemaSource.slice(objectStart, objectEnd + 1); + + return { objectBlock }; +} + +function extractExportSource(name: string) { + const marker = `export const ${name} = `; + const declarationIndex = auditsSource.indexOf(marker); + assert.notEqual( + declarationIndex, + -1, + `Expected declaration for ${name}.`, + ); + + const openBraceIndex = auditsSource.indexOf("{", declarationIndex); + let depth = 0; + let end = -1; + + for (let index = openBraceIndex; index < auditsSource.length; index += 1) { + const char = auditsSource[index]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + end = index; + break; + } + } + } + + assert.notEqual( + end, + -1, + `Expected balanced braces for export ${name}.`, + ); + return auditsSource.slice(openBraceIndex, end + 1); +} + +function extractFieldSection(source: string, fieldName: string, nextFieldName: string) { + const match = source.match( + new RegExp( + `${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`, + ), + ); + + assert.notEqual( + match, + null, + `Expected ${fieldName} field with expected object structure in schema.`, + ); + + return match![0]; +} + +function hasPattern(source: string, pattern: RegExp, message: string) { + assert.equal(pattern.test(source), true, message); +} + +test("audits schema stores compact usedSkills metadata", () => { + const { objectBlock } = extractTableSection("audits"); + const usedSkillsSection = extractFieldSection( + objectBlock, + "usedSkills", + "skillSummaries", + ); + const skillSummariesSection = extractFieldSection( + objectBlock, + "skillSummaries", + "multimodalSummary", + ); + + hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional."); + hasPattern( + usedSkillsSection, + /name:\s*v\.string\(\)/, + "usedSkills.name should be string.", + ); + hasPattern( + usedSkillsSection, + /category:\s*v\.string\(\)/, + "usedSkills.category should be string.", + ); + hasPattern( + usedSkillsSection, + /version:\s*v\.optional\(\s*v\.string\(\)\s*\)/, + "usedSkills.version should be optional string.", + ); + hasPattern( + usedSkillsSection, + /source:\s*v\.optional\(\s*v\.string\(\)\s*\)/, + "usedSkills.source should be optional string.", + ); + hasPattern( + usedSkillsSection, + /v\.array\(/, + "usedSkills should be an optional array of objects.", + ); + hasPattern( + usedSkillsSection, + /v\.object\(/, + "usedSkills should be defined with v.object fields.", + ); + + hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist."); + hasPattern( + skillSummariesSection, + /name:\s*v\.string\(\)/, + "skillSummaries.name should stay string.", + ); + hasPattern( + skillSummariesSection, + /purpose:\s*v\.string\(\)/, + "skillSummaries.purpose should stay string.", + ); + hasPattern( + skillSummariesSection, + /summary:\s*v\.string\(\)/, + "skillSummaries.summary should stay string.", + ); +}); + +test("audits.create accepts usedSkills validator and persists metadata payloads", () => { + const createSource = extractExportSource("create"); + + hasPattern( + auditsSource, + /const usedSkillsValidator\s*=\s*v\.array\(/, + "audits.ts should define a reusable usedSkillsValidator.", + ); + hasPattern( + auditsSource, + /v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/, + "audits.ts should define a reusable usedSkillsValidator.", + ); + + hasPattern( + createSource, + /usedSkills:\s*v\.optional\(usedSkillsValidator\)/, + "create args should include optional usedSkills field.", + ); + hasPattern( + createSource, + /ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/, + "create should persist audit payload from args (so usedSkills is stored when provided).", + ); +}); + +test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => { + const getDetailSource = extractExportSource("getDetail"); + + hasPattern( + getDetailSource, + /args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/, + "getDetail should require id argument for audits.", + ); + hasPattern( + getDetailSource, + /const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/, + "getDetail should load audit by id.", + ); + hasPattern( + getDetailSource, + /if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/, + "getDetail should return null when audit is missing.", + ); + hasPattern( + getDetailSource, + /const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/, + "getDetail should load lead by leadId from the audit.", + ); + hasPattern( + getDetailSource, + /return\s*{\s*audit,\s*lead\s*}/, + "getDetail should return { audit, lead }.", + ); + hasPattern( + sourceFile.getFullText(), + /export const getDetail = query\(/, + "audits.ts should export a getDetail query.", + ); +}); diff --git a/tests/audit-skills-ui.test.ts b/tests/audit-skills-ui.test.ts new file mode 100644 index 0000000..612df85 --- /dev/null +++ b/tests/audit-skills-ui.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +const source = async (relativePath: string) => { + return await readFile( + join(process.cwd(), ...relativePath.split("/")), + "utf8", + ); +}; + +test("audits dashboard page uses a dedicated board component", async () => { + const dashboardPageSource = await source("app/dashboard/audits/page.tsx"); + + assert.doesNotMatch( + dashboardPageSource, + /DashboardPlaceholderPage/i, + "Dashboard audits route should not render the placeholder page.", + ); + assert.match( + dashboardPageSource, + //, + "Audits board should be mounted from route page.", + ); + assert.match( + dashboardPageSource, + /"@\/components\/audits\/audits-board"/, + "Audits board should be imported from components.", + ); +}); + +test("audits board renders compact list with convex list query and core columns", async () => { + const boardSource = await source("components/audits/audits-board.tsx"); + + assert.match( + boardSource, + /\"use client\"/, + "AuditsBoard must be a Client Component for useQuery.", + ); + assert.match( + boardSource, + /useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/, + "AuditsBoard should call api.audits.list with { limit: 100 }.", + ); + assert.match( + boardSource, + /sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/, + "Audits should be sorted newest first.", + ); + assert.match(boardSource, /Loading|lädt|Lade/i); + assert.match(boardSource, /Keine Audits|keine Audits/i); + assert.match(boardSource, /Slug/); + assert.match(boardSource, /Domain/); + assert.match(boardSource, /Status/); + assert.match(boardSource, /Seiten/); + assert.match( + boardSource, + /href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/, + "Each audit row should link to /dashboard/audits/{id}.", + ); +}); + +test("audit detail component uses getDetail query and renders skills overview section", async () => { + const detailSource = await source("components/audits/audit-detail.tsx"); + + assert.match( + detailSource, + /\"use client\"/, + "AuditDetail must be client-side for Convex query calls.", + ); + assert.match( + detailSource, + /api\.audits[\s\S]{0,80}getDetail/, + "AuditDetail should use api.audits.getDetail query.", + ); + assert.match( + detailSource, + /useQuery\(\s*api\.audits\.getDetail,\s*\{/, + "AuditDetail should call useQuery with api.audits.getDetail directly.", + ); + assert.doesNotMatch( + detailSource, + /const\s+auditDetailQueryRef/, + "AuditDetail should not use a cast-based query fallback variable.", + ); + assert.match( + detailSource, + /const\s+audit\s*=\s*result\?\.audit;/, + "AuditDetail should destructure audit from result.audit.", + ); + assert.match( + detailSource, + /const\s+lead\s*=\s*result\?\.lead;/, + "AuditDetail should destructure lead from result.lead.", + ); + assert.match( + detailSource, + /leadSummary\(\s*lead\s*\)/, + "AuditDetail should pass lead into leadSummary from result.lead.", + ); + assert.match( + detailSource, + /usedSkills/, + "AuditDetail should inspect usedSkills for overview rendering.", + ); + assert.match( + detailSource, + /Keine Skills gespeichert/, + "AuditDetail should show fallback text when no skills are saved.", + ); + assert.match( + detailSource, + /Verwendete Skills/, + "AuditDetail should render Verwendete Skills heading.", + ); + assert.match( + detailSource, + /Lead|lead/, + "AuditDetail should surface lead context when available.", + ); + assert.doesNotMatch( + detailSource, + /]*>\s*\{leadSummary\(\s*lead\|[\s\S]*?\)\s*\}\s*<\/p>/, + "Lead summary should not wrap leadSummary output in a nested

.", + ); + assert.doesNotMatch( + detailSource, + /]*>\s*\{leadSummary\(\s*audit\.lead\)\s*\}\s*<\/p>/, + "Lead summary should not wrap leadSummary output in a nested

.", + ); +}); + +test("audits detail route passes id to AuditDetail via Promise params", async () => { + const pageSource = await source("app/dashboard/audits/[id]/page.tsx"); + + assert.match( + pageSource, + /params:\s*Promise<\{\s*id:\s*string\s*\}>/, + "Audit detail route should accept params as Promise in Next.js 16 style.", + ); + assert.match( + pageSource, + /const \{\s*id\s*\}\s*=\s*await params/, + "Audit detail route should unwrap Promise params.", + ); + assert.match( + pageSource, + / { + const publicAuditSource = await source("app/audit/[slug]/page.tsx"); + + assert.doesNotMatch( + publicAuditSource, + /Verwendete Skills|usedSkills/i, + "Public audit page must not show used skills.", + ); +}); diff --git a/tests/skills-registry.test.ts b/tests/skills-registry.test.ts new file mode 100644 index 0000000..9a7d638 --- /dev/null +++ b/tests/skills-registry.test.ts @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import test from "node:test"; + +import { + type AuditUsedSkill, + loadSkillsRegistry, + parseSkillsRegistry, + toAuditUsedSkill, + SKILL_CATEGORIES, +} from "../lib/skills-registry"; + +function assertIncludes(values: readonly string[], value: string) { + assert.ok(values.includes(value), `Expected ${value} in [${values.join(", ")}]`); +} + +function withTempProjectRegistry( + source: string, + run: () => Promise | void, +) { + return mkdtemp(`${tmpdir()}${sep}`).then(async (projectRoot) => { + const registryPath = join(projectRoot, "skills.md"); + const originalCwd = process.cwd(); + await writeFile(registryPath, source, "utf8"); + process.chdir(projectRoot); + + try { + await run(); + } finally { + process.chdir(originalCwd); + await rm(projectRoot, { recursive: true, force: true }); + } + }); +} + +test("parseSkillsRegistry parses valid entries, trims whitespace, and normalizes category", async () => { + const registrySource = ` +## Design Audit +Purpose: Evaluate layout, visual hierarchy, and CTA clarity. +When to use: Use when a homepage is available and should be assessed for conversion quality. +When not to use: Don't run during technical outages or non-web touchpoints. +Required input: Homepage URL, top-level page sections, style language, brand context. +Expected output: Prioritized improvement list with concrete design changes. +Category: design +Version: 2026.06 +Source: skills/design-audit.md +`; + + const parsed = parseSkillsRegistry(registrySource); + + assert.equal(parsed.length, 1); + const entry = parsed.at(0); + assert.ok(entry); + assert.equal(entry!.name, "Design Audit"); + assert.equal(entry!.purpose, "Evaluate layout, visual hierarchy, and CTA clarity."); + assert.equal(entry!.category, "design"); + assert.equal(entry!.version, "2026.06"); + assert.equal(entry!.source, "skills/design-audit.md"); +}); + +test("parseSkillsRegistry accepts indented field labels", () => { + const registrySource = ` +## Local SEO Boost + Purpose: Evaluate visibility for local search with nearby intent. + When to use: Use for local business pages and service locations. + When not to use: Avoid for non-local marketing pages. + Required input: City, address, NAP consistency. + Expected output: Prioritized local SEO recommendations. + Category: seo + `; + + const parsed = parseSkillsRegistry(registrySource); + + assert.equal(parsed.length, 1); + const entry = parsed.at(0); + assert.ok(entry); + assert.equal(entry!.name, "Local SEO Boost"); + assert.equal(entry!.purpose, "Evaluate visibility for local search with nearby intent."); + assert.equal(entry!.category, "seo"); +}); + +test("parseSkillsRegistry throws for missing required fields", () => { + const registrySource = ` +## UX Friction Review +Purpose: Review interaction patterns for friction points. +When to use: Use for lead capture and booking flows. +When not to use: Use only when there is a user journey. +Required input: Session flow and target action. +Category: ux +`; + + assert.throws( + () => parseSkillsRegistry(registrySource), + /missing required field "Expected output"/i, + ); +}); + +test("parseSkillsRegistry throws for unknown category", () => { + const registrySource = ` +## Bad Category Example +Purpose: Example. +When to use: Example scenario. +When not to use: Never for this case. +Required input: Example data. +Expected output: Example output. +Category: analytics +`; + + assert.throws( + () => parseSkillsRegistry(registrySource), + /unknown category "analytics"/i, + ); +}); + +test("parseSkillsRegistry throws for duplicate skill names", () => { + const registrySource = ` +## Local SEO Boost +Purpose: Strengthen local SERPs. +When to use: Use for local service businesses. +When not to use: Not for international-only landing pages. +Required input: Name, address, service area. +Expected output: Local SEO gaps and quick wins. +Category: seo + +## Local SEO Boost +Purpose: Another local SEO pass. +When to use: Use for new regions. +When not to use: Skip for pure lead-gen pages. +Required input: Name, address, service area. +Expected output: Competitor baseline. +Category: seo +`; + + assert.throws( + () => parseSkillsRegistry(registrySource), + /duplicate skill name "Local SEO Boost"/i, + ); +}); + +test("parseSkillsRegistry accepts all configured categories", () => { + assertIncludes(SKILL_CATEGORIES, "design"); + assertIncludes(SKILL_CATEGORIES, "ux"); + assertIncludes(SKILL_CATEGORIES, "marketing"); + assertIncludes(SKILL_CATEGORIES, "copy"); + assertIncludes(SKILL_CATEGORIES, "seo"); + assertIncludes(SKILL_CATEGORIES, "offer"); + + const registrySource = SKILL_CATEGORIES.map( + (category) => ` +## ${category}-skill +Purpose: Valid for ${category}. +When to use: Use for ${category} tasks. +When not to use: Skip when ${category} is not in scope. +Required input: Category inputs. +Expected output: Category-specific recommendations. +Category: ${category} +`, + ).join("\n\n"); + + const parsed = parseSkillsRegistry(registrySource); + assert.equal(parsed.length, SKILL_CATEGORIES.length); + for (const category of SKILL_CATEGORIES) { + const match = parsed.find((entry) => entry.name === `${category}-skill`); + assert.ok(match, `Expected parsed entry for ${category}`); + assert.equal(match.category, category); + } +}); + +test("loadSkillsRegistry reads skills.md from process.cwd() by default", async () => { + await withTempProjectRegistry( + ` +## Offer Writing +Purpose: Build offer-focused copy for outreach. +When to use: Use before drafting proposals. +When not to use: Avoid when no offer exists. +Required input: Offer structure and pricing envelope. +Expected output: Offer draft and pricing emphasis. +Category: offer +`, + async () => { + const parsed = await loadSkillsRegistry(); + const parsedEntry = parsed.find((entry) => entry.name === "Offer Writing"); + + assert.ok(parsedEntry); + assert.equal(parsedEntry.category, "offer"); + }, + ); +}); + +test("loadSkillsRegistry accepts an explicit registry path", async () => { + const projectRoot = await mkdtemp(`${tmpdir()}${sep}`); + const registryPath = join(projectRoot, "seed-skills.md"); + + await writeFile( + registryPath, + ` +## Design Audit +Purpose: Validate design quality for local business pages. +When to use: Use for a quick visual prioritization pass. +When not to use: Skip when no public page exists. +Required input: Homepage URL and target conversion goal. +Expected output: Ranked design actions with confidence. +Category: design +`, + "utf8", + ); + + try { + const parsed = await loadSkillsRegistry(registryPath); + assert.equal(parsed.at(0)?.name, "Design Audit"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("toAuditUsedSkill returns only required audit-facing fields", () => { + const skill = { + name: "Copy Clarity", + purpose: "Reduce complexity and improve readability.", + whenToUse: "When existing copy is verbose.", + whenNotToUse: "Skip if website is plain text-only.", + requiredInput: "Page sections and CTAs.", + expectedOutput: "A concise writing pass.", + category: "copy", + version: "1.0", + source: "skills/copy-clarity.md", + } satisfies { + name: string; + purpose: string; + whenToUse: string; + whenNotToUse: string; + requiredInput: string; + expectedOutput: string; + category: "copy"; + version: string; + source: string; + }; + + const auditUsed = toAuditUsedSkill(skill); + const expected: AuditUsedSkill = { + name: "Copy Clarity", + category: "copy", + version: "1.0", + source: "skills/copy-clarity.md", + }; + + assert.deepEqual(auditUsed, expected); +});