diff --git a/.claude/skills/competitor-scan/SKILL.md b/.claude/skills/competitor-scan/SKILL.md index cb0441f..d434ec0 100644 --- a/.claude/skills/competitor-scan/SKILL.md +++ b/.claude/skills/competitor-scan/SKILL.md @@ -31,6 +31,7 @@ Goal: tell the client who they're actually up against, who's winning and *why*, - ```` ```mc:rivals-tags ```` hashtags the winners use that the client doesn't. - ```` ```mc:rivals-posts ```` the best posts in the market, as an idea bank. - ```` ```mc:rivals-google ```` Google rating and reviews (local businesses). + - ```` ```mc:search-trends ```` search demand for the gaps: first line the exact keywords of one `google_trends` call, comma-separated; second line its location. Only when you called `google_trends`; put it right after the gaps. Order: Summary (3 sentences: where the client stands, who's leading and why, the biggest opening) → `mc:rivals` → `mc:rivals-map` → **Who's winning and why** (one `> [!NOTE]` per top 2–3 rivals: what they do that works) → `mc:rivals-formats` → `mc:rivals-posts` → `mc:rivals-tags` → `mc:rivals-google` if local → **Openings nobody owns** (`> [!WIN]` per gap, 3–5) → **What we'll do** (`mc:actions`, 5 items, owner + when) → **Who to track** (list the suggested accounts and say the team will confirm them). Never copy a rival's content or suggest misleading comparisons; learn the pattern, not the post. diff --git a/.claude/skills/trend-scan/SKILL.md b/.claude/skills/trend-scan/SKILL.md index 010c0f1..f90bd43 100644 --- a/.claude/skills/trend-scan/SKILL.md +++ b/.claude/skills/trend-scan/SKILL.md @@ -15,7 +15,7 @@ Works only with official data: the `competitor_lookup` tool (Instagram Business 5. Group into 3–5 patterns. A pattern needs 2+ outliers from different accounts — one viral post is luck, not a trend. 6. Check the client's own last 30 posts the same way (`instagram_recent_posts`) to show which of their posts were outliers and why. 6b. **Hashtag top posts** (`hashtag_top_posts`, skip if it says it's switched off): 3–5 specific niche or local hashtags (from the client's best posts and rivals' `topTags`; never generic tags like #food), kind=top. Instagram allows 30 different hashtags per account per week, so reuse last week's tags where you can. Treat a Reel here as an outlier when it clearly beats the other posts under the same tag; group them with the account outliers in step 5. These posts come without a username: link them, don't credit or name the poster. -7. **What people are searching for** (`google_trends`, skip if it says it isn't set up): 2–3 calls of up to 5 keywords each, in the client's region (location from `client.json`, e.g. "Ontario,Canada"), `past_12_months`. Use the words customers type ("emergency dentist", "odoo erp"), not industry jargon. Note keywords rising (positive `change`), the rising related searches (new questions to answer in content) and the peak months (post before the peak, not during it). Put these in their own "Search trends" section; they're Google searches, not Instagram trends. +7. **What people are searching for** (`google_trends`, skip if it says it isn't set up): 2–3 calls of up to 5 keywords each, in the client's region (location from `client.json`, e.g. "Ontario,Canada"), `past_12_months`. Use the words customers type ("emergency dentist", "odoo erp"), not industry jargon. Note keywords rising (positive `change`), the rising related searches (new questions to answer in content) and the peak months (post before the peak, not during it). Put these in their own "Search trends" section with one ```` ```mc:search-trends ```` block per call (first line the exact keywords, comma-separated; second line the location; the chart is drawn from the stored answer); they're Google searches, not Instagram trends. 8. Optional: web search " Instagram trend " and Meta's own creator/trending pages for audio and formats this month; mark anything you could not verify. ## Output → `clients//trends/YYYY-MM-DD.md` diff --git a/dashboard/server/clients.js b/dashboard/server/clients.js index 6174bc5..be59efa 100644 --- a/dashboard/server/clients.js +++ b/dashboard/server/clients.js @@ -6,7 +6,7 @@ import { CLIENTS_DIR, TEMPLATE_DIR } from "./config.js"; import * as meta from "../../connector/src/meta.js"; const SKIP = new Set(["_template"]); -const DOC_DIRS = { reports: "reports", calendar: "calendar" }; +const DOC_DIRS = { reports: "reports", calendar: "calendar", trends: "trends" }; export const slugify = (s) => String(s).toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/&/g, "and").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-"); @@ -66,7 +66,7 @@ export function listDocs(slug) { for (const [kind, sub] of Object.entries(DOC_DIRS)) { const d = path.join(dir, sub); if (!fs.existsSync(d)) continue; - for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : "calendar"); + for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : kind); } for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup"); return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt)); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index a7c8c1c..bbc3f69 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -491,7 +491,11 @@ function ownForCompare(slug) { history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })), }; } -app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); })); +app.get("/api/competitors", h(async (req) => { + const c = String(req.query.client || ""); clients.safeSlug(c); + const report = clients.listDocs(c).filter((d) => d.kind === "competitors").sort((a, b) => b.path.localeCompare(a.path))[0] || null; + return { ...competitors.compare(c, ownForCompare(c)), report: report && { path: report.path, title: report.title, updatedAt: report.updatedAt } }; +})); app.post("/api/competitors", h(async (req) => ({ added: await competitors.add(String(req.body?.client || ""), req.body?.username, { by: who(req), note: req.body?.note || "" }) }))); app.delete("/api/competitors/:client/:username", h(async (req) => { competitors.remove(req.params.client, req.params.username); return { ok: true }; })); app.get("/api/competitors/:client/suggestions", h(async (req) => ({ suggestions: competitors.listSuggestions(req.params.client) }))); diff --git a/dashboard/web/src/pages/Client.jsx b/dashboard/web/src/pages/Client.jsx index ef12c7f..e0ae0ef 100644 --- a/dashboard/web/src/pages/Client.jsx +++ b/dashboard/web/src/pages/Client.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react"; import { api } from "../api.js"; @@ -31,7 +31,8 @@ export default function Client() { const nav = useNavigate(); const [data, setData] = useState(null); const [error, setError] = useState(null); - const [tab, setTab] = useState("performance"); + const [params] = useSearchParams(); + const [tab, setTab] = useState(params.get("tab") || "performance"); const [runOpen, setRunOpen] = useState(false); const [linkOpen, setLinkOpen] = useState(false); const { isAdmin } = useAuth(); @@ -119,7 +120,7 @@ export default function Client() { ))} - {tab === "docs" &&
} + {tab === "docs" &&
} {tab === "runs" && } {tab === "shots" && } {tab === "brand" && } @@ -387,9 +388,9 @@ function FormatBars({ d }) { } // ---------------------------------------------------------------- documents -const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; +const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", competitors: "Competitor analysis", trends: "Trend scans", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; -function Documents({ slug, docs, reload }) { +function Documents({ slug, docs, reload, openPath }) { const [preview, setPreview] = useState(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); @@ -403,6 +404,11 @@ function Documents({ slug, docs, reload }) { setError(e); } }; + // Deep link from other pages (e.g. Competitors → "Open report"): ?tab=docs&doc= + useEffect(() => { + const doc = openPath && docs.find((d) => d.path === openPath); + if (doc) open(doc); + }, [openPath]); // eslint-disable-line react-hooks/exhaustive-deps const exportPdf = async (doc) => { setBusy(doc.path); setError(null); diff --git a/dashboard/web/src/pages/Competitors.jsx b/dashboard/web/src/pages/Competitors.jsx index e3e3649..e8afe49 100644 --- a/dashboard/web/src/pages/Competitors.jsx +++ b/dashboard/web/src/pages/Competitors.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import { Check, ExternalLink, Hash, Plus, RefreshCw, Sparkles, Swords, Trash2, X } from "lucide-react"; +import { Check, ExternalLink, FileBarChart, Hash, Plus, RefreshCw, Sparkles, Swords, Trash2, X } from "lucide-react"; import { api } from "../api.js"; import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx"; @@ -199,6 +199,7 @@ export default function Competitors() { + {data?.report && } {data?.competitors.length > 0 && } diff --git a/tools/report-pdf/blocks.mjs b/tools/report-pdf/blocks.mjs index 386427e..fb1639f 100644 --- a/tools/report-pdf/blocks.mjs +++ b/tools/report-pdf/blocks.mjs @@ -5,6 +5,7 @@ // Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts // Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When) // ```mc:screenshot (first line: shot id, then one note per line) +// ```mc:search-trends (first line: keywords of one google_trends call, optional second: location) // Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE] import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { dirname, extname, join, resolve, sep } from "node:path"; @@ -53,7 +54,16 @@ function freshData(slug) { }; const competition = competitionData(slug, snap); const fb = snap.fb && { name: snap.fb.page?.name, followers: snap.fb.page?.followers_count, totals: snap.fb.totals || {}, posts: (snap.fb.posts || []).map((p) => ({ at: p.created_time })) }; - return { slug, asOf: snap.fetchedAt, ig, fb, competition }; + return { slug, asOf: snap.fetchedAt, ig, fb, competition, trends: trendsData() }; +} + +/** Google Trends answers the runs fetched in the last week (connector/src/trends.js cache), frozen with the report. */ +function trendsData() { + try { + return Object.values(readJson(join(root, "data", "trends-cache.json"))).map((v) => v.data).filter((t) => t?.keywords?.length); + } catch { + return []; + } } /** The client plus tracked and suggested competitors, with the numbers the rival blocks need. */ @@ -350,6 +360,41 @@ function actionsBlock(body) { } const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" }; +/** + * ```mc:search-trends first line: the keywords of one google_trends call (comma-separated); optional second line: location. + * Draws interest over time per keyword plus the rising searches, from the stored answer (never typed numbers). + */ +const TREND_COLORS = [C.blue, "#fc6d45", C.good, C.warn, C.sky]; +function trendsBlock(body, d) { + const [kwLine = "", locLine = ""] = String(body).split("\n").map((l) => l.trim()).filter(Boolean); + const want = kwLine.toLowerCase().split(",").map((k) => k.trim()).filter(Boolean); + const loc = locLine.toLowerCase(); + const sets = (d?.trends || []).filter((t) => !loc || String(t.location).toLowerCase() === loc); + const t = sets.find((s) => want.every((k) => s.keywords.some((x) => x.keyword === k))) || (!want.length ? sets[0] : null); + if (!t) return `

Search trends${want.length ? ` for ${esc(want.join(", "))}` : ""}: no stored Google Trends answer.

`; + const kws = want.length ? t.keywords.filter((k) => want.includes(k.keyword)) : t.keywords; + const n = Math.max(...kws.map((k) => k.series.length)); + if (n < 2) return `

Search trends: not enough data points.

`; + + const W = 640, H = 190, L = 30, R = 10, T = 12, B = 30; + const x = (i) => L + (i * (W - L - R)) / (n - 1), y = (v) => T + (H - T - B) * (1 - v / 100); + const grid = [0, 50, 100].map((v) => `${v}`).join(""); + const lines = kws.map((k, i) => ``).join(""); + const dates = kws.find((k) => k.series.length === n).series.map(([from]) => from); + const labels = [0, Math.floor((n - 1) / 2), n - 1].map((i) => `${new Date(dates[i]).toLocaleDateString("en-CA", { month: "short", year: "2-digit", timeZone: "UTC" })}`).join(""); + const svg = `${grid}${lines}${labels}`; + + const month = (iso) => (iso ? new Date(iso).toLocaleDateString("en-CA", { month: "short", year: "numeric", timeZone: "UTC" }) : "—"); + const rows = kws.map((k, i) => { + const ch = k.change == null ? "—" : `${k.change > 0 ? "▲" : k.change < 0 ? "▼" : ""} ${Math.abs(k.change)}%`; + const tone = k.change == null ? "" : k.change >= 10 ? ` style="color:${C.good}"` : k.change <= -10 ? ` style="color:${C.bad}"` : ""; + const rising = (k.rising || []).slice(0, 5).map((r) => `${esc(r.query)}`).join(""); + return `${esc(k.keyword)}${ch}${month(k.peak?.from)}
${rising || "—"}
`; + }).join(""); + const table = `${rows}
SearchTrendBusiestRising related searches
`; + return card("", "What people are searching for on Google", `${esc(String(t.location).replace(/,\s*/g, ", "))} · interest from 0 to 100, relative to the busiest point · trend = latest quarter of the period vs the one before`, svg + table); +} + function screenshotBlock(body, ctx) { const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const dir = join(dirname(ctx.mdPath), "screenshots"); @@ -381,6 +426,7 @@ export function renderMarkdown(md, ctx) { if (kind === "score") return scoreBlock(code); if (kind === "actions") return actionsBlock(code); if (kind === "screenshot") return screenshotBlock(code, ctx); + if (kind === "search-trends") return trendsBlock(code, data()); return ctx.internal ? `

Unknown block mc:${esc(kind)}

` : ""; }, blockquote(quote) {