diff --git a/app/src/App.tsx b/app/src/App.tsx index b102018..9ee9149 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { AuditTab } from "./tabs/AuditTab"; import { ProjectsTab } from "./tabs/ProjectsTab"; import { ReposTab } from "./tabs/ReposTab"; import { SettingsTab } from "./tabs/SettingsTab"; @@ -9,7 +8,6 @@ import { TeamProvider, useTeam, type TeamId } from "./teamContext"; import { useSetupStatus, SetupBanner } from "./SetupStatus"; const TABS = [ - { id: "audit", label: "Activity", Comp: AuditTab }, { id: "projects", label: "Projects", Comp: ProjectsTab }, { id: "repos", label: "Repos", Comp: ReposTab }, { id: "settings", label: "Settings", Comp: SettingsTab }, @@ -32,13 +30,13 @@ function Inner() { const setup = useSetupStatus(teamId); // First-signin default: a fresh user with no required setup done - // lands on settings (wizard) instead of an empty Activity tab. The - // choice is sticky once they navigate (recorded in localStorage), - // so we don't keep forcing settings on every page load. + // lands on settings (wizard). The choice is sticky once they navigate + // (recorded in localStorage), so we don't keep forcing settings on + // every page load. const [tab, setTab] = useState<(typeof TABS)[number]["id"]>(() => { - if (typeof window === "undefined") return "audit"; + if (typeof window === "undefined") return "projects"; const saved = window.localStorage.getItem("otto.lastTab"); - return saved && isTabId(saved) ? saved : "audit"; + return saved && isTabId(saved) ? saved : "projects"; }); const Comp = TABS.find((t) => t.id === tab)!.Comp; @@ -50,7 +48,7 @@ function Inner() { typeof window !== "undefined" ? window.localStorage.getItem("otto.lastTab") : null; - if (!saved && tab === "audit" && setup.done === 0) setTab("settings"); + if (!saved && tab === "projects" && setup.done === 0) setTab("settings"); }, [setup.loading, setup.done, tab]); // Persist the selected tab so refreshes don't bounce people back @@ -75,6 +73,7 @@ function Inner() { + setTab("settings")} />
{TABS.map((t) => (
- setTab("settings")} /> {!teamId ? (

{bootstrapping || teams === undefined diff --git a/app/src/styles.css b/app/src/styles.css index 2cd125f..d877531 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -551,3 +551,52 @@ a:hover { color: var(--otto-ink); text-decoration-color: var(--otto-ink); } font-style: italic; font-weight: 400; } + +/* Project grid — clickable cards in the merged Projects tab. */ +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--otto-space-3); + margin-top: var(--otto-space-3); + margin-bottom: var(--otto-space-6); +} +.project-card { + appearance: none; + text-align: left; + width: 100%; + background: var(--otto-cream); + border: var(--otto-border-hair); + padding: var(--otto-space-4) var(--otto-space-5); + cursor: pointer; + font: inherit; + color: inherit; + transition: background 0.1s ease, border-color 0.1s ease; +} +.project-card:hover { + background: var(--otto-bg); + border-color: var(--otto-ink); +} +.project-card:focus-visible { + outline: 2px solid var(--otto-amber, #c89045); + outline-offset: 2px; +} + +/* Modal — used by CreateProjectModal in the projects tab. */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(28, 26, 22, 0.55); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: var(--otto-space-4); +} +.modal-panel { + background: var(--otto-cream); + border: var(--otto-border-hair); + padding: var(--otto-space-5) var(--otto-space-6); + width: 100%; + max-width: 420px; + box-shadow: 6px 6px 0 rgba(28, 26, 22, 0.15); +} diff --git a/app/src/tabs/AuditTab.tsx b/app/src/tabs/AuditTab.tsx deleted file mode 100644 index 2fdabc8..0000000 --- a/app/src/tabs/AuditTab.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useQuery } from "convex/react"; -import { api } from "../convexApi"; -import { OttoHero, StatCard } from "../Otto"; -import { useTeam } from "../teamContext"; - -export function AuditTab() { - const { teamId } = useTeam(); - const items = useQuery( - api.admin.recentItems, - teamId ? { teamId, limit: 100 } : "skip", - ); - const log = useQuery( - api.admin.recentAuditLog, - teamId ? { teamId, limit: 200 } : "skip", - ); - - const stats = items ? computeStats(items) : null; - - return ( - <> - {stats && ( -

- - ITEMS // ALL TIME - - } - value={stats.total} - caption={`${stats.last7d} in the last 7 days`} - /> - - PRS DRAFTED // ALL TIME - - } - value={stats.prsOpened} - caption={`${stats.prsLast7d} in the last 7 days`} - accent - /> - - AWAITING // SLACK QUEUE - - } - value={stats.queued} - caption={stats.queued === 0 ? "all clear" : "needs review"} - /> -
- )} - -
- ACTIVITY // RECENT ITEMS -
- - {!items ? ( -

Loading…

- ) : items.length === 0 ? ( -
- -

No items yet.

-

- Drop the widget onto a page to wake Otto up. -

-
- ) : ( - - - - - - - - - - - - {items.slice(0, 50).map((it) => ( - - - - - - - - ))} - -
statuswhat otto's doingsourceconfidencepr
- - {statusLabel(it.status)} - - {it.description} - {shortSource(it.sourceRef)} - - {(it.parserConfidence * (it.routerConfidence ?? 0)).toFixed(2)} - - {it.prUrl ? ( - - open - - ) : ( - - )} -
- )} - -
- AUDIT LOG // RAW EVENTS -
- {!log ? ( -

Loading…

- ) : log.length === 0 ? ( -
-

Nothing yet.

-
- ) : ( - - - - - - - - - - - {log.map((row) => ( - - - - - - - ))} - -
wheneventactoritem
- {relativeTime(row.at)} - - {row.event} - - {row.actor} - - {row.itemId ? row.itemId.slice(-8) : "—"} -
- )} - - ); -} - -function computeStats(items: { createdAt: number; status: string }[]) { - const now = Date.now(); - const sevenDays = 7 * 24 * 60 * 60 * 1000; - const recent = items.filter((i) => now - i.createdAt < sevenDays); - return { - total: items.length, - last7d: recent.length, - prsOpened: items.filter((i) => i.status === "pr_opened").length, - prsLast7d: recent.filter((i) => i.status === "pr_opened").length, - queued: items.filter((i) => i.status === "queued").length, - }; -} - -function statusLabel(status: string): string { - switch (status) { - case "parsed": return "parsed"; - case "queued": return "awaiting review"; - case "approved": return "approved"; - case "fired": return "working"; - case "pr_opened": return "pr open"; - case "failed": return "failed"; - case "rejected": return "rejected"; - default: return status; - } -} - -function shortSource(ref: string): string { - try { - const u = new URL(ref); - return `${u.host}${u.pathname === "/" ? "" : u.pathname}`; - } catch { - return ref; - } -} - -function relativeTime(ms: number): string { - const diff = (Date.now() - ms) / 1000; - if (diff < 60) return `${Math.round(diff)}s ago`; - if (diff < 3600) return `${Math.round(diff / 60)}m ago`; - if (diff < 86400) return `${Math.round(diff / 3600)}h ago`; - return new Date(ms).toLocaleDateString(); -} diff --git a/app/src/tabs/ProjectsTab.tsx b/app/src/tabs/ProjectsTab.tsx index ca57d7e..805a771 100644 --- a/app/src/tabs/ProjectsTab.tsx +++ b/app/src/tabs/ProjectsTab.tsx @@ -1,24 +1,23 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { api } from "../convexApi"; import { useTeam } from "../teamContext"; +import { OttoHero, StatCard } from "../Otto"; import type { Doc, Id } from "../../../convex/_generated/dataModel"; -// projects.list returns the stored project shape plus two joined -// fields the query computes: primaryRepoName + repoCount. +// projects.list returns the stored project shape plus two joined fields +// the query computes: primaryRepoName + repoCount. type ProjectRow = Doc<"projects"> & { primaryRepoName: string | null; repoCount: number; }; -const BLANK_DRAFT = { - name: "", - slug: "", - description: "", - urlPatterns: "", - primaryRepoId: "", - enabled: true, -}; +type Item = Doc<"items">; + +// Tab-internal "view" — grid (everyone) or detail (one project zoomed in). +// We keep this as component state rather than wiring real URL routing; +// when we want shareable links we'll bolt on a router. +type View = { kind: "grid" } | { kind: "detail"; projectId: Id<"projects"> }; export function ProjectsTab() { const { teamId } = useTeam(); @@ -26,16 +25,504 @@ export function ProjectsTab() { api.projects.list, teamId ? { teamId } : "skip", ) as ProjectRow[] | undefined; + const items = useQuery( + api.admin.recentItems, + teamId ? { teamId, limit: 200 } : "skip", + ) as Item[] | undefined; + + const [view, setView] = useState({ kind: "grid" }); + + if (view.kind === "detail") { + const project = projects?.find((p) => p._id === view.projectId); + if (!project) { + // Project deleted out from under us, or still loading. + return ( +

+ {projects ? "project not found." : "loading…"} +

+ ); + } + return ( + setView({ kind: "grid" })} + /> + ); + } + + return ( + setView({ kind: "detail", projectId: id })} + /> + ); +} + +/* ─────────────────────── grid view ─────────────────────── */ + +function ProjectsGrid({ + projects, + items, + onOpen, +}: { + projects: ProjectRow[] | undefined; + items: Item[] | undefined; + onOpen: (id: Id<"projects">) => void; +}) { + const stats = useMemo(() => (items ? computeStats(items) : null), [items]); + const [creating, setCreating] = useState(false); + + return ( + <> + {stats && ( +
+ + ITEMS // ALL TIME + + } + value={stats.total} + caption={`${stats.last7d} in the last 7 days`} + /> + + PRS DRAFTED // ALL TIME + + } + value={stats.prsOpened} + caption={`${stats.prsLast7d} in the last 7 days`} + accent + /> + + AWAITING // SLACK QUEUE + + } + value={stats.queued} + caption={stats.queued === 0 ? "all clear" : "needs review"} + /> +
+ )} + +
+

projects

+ +
+ + {creating && ( + setCreating(false)} + onCreated={(id) => { + setCreating(false); + onOpen(id); + }} + /> + )} + + {!projects ? ( +

loading…

+ ) : projects.length === 0 ? ( +
+ +

+ create a project to start collecting widget feedback. +

+
+ ) : ( +
+ {projects.map((p) => ( + it.projectId === p._id) ?? []} + loading={!items} + onClick={() => onOpen(p._id)} + /> + ))} +
+ )} + + ); +} + +function ProjectCard({ + project, + items, + loading, + onClick, +}: { + project: ProjectRow; + items: Item[]; + loading: boolean; + onClick: () => void; +}) { + const sevenDays = 7 * 24 * 60 * 60 * 1000; + const recent = items.filter((i) => Date.now() - i.createdAt < sevenDays); + const drafts = items.filter((i) => i.status === "pr_opened").length; + const installed = items.length > 0 || loading; + + return ( + + ); +} + +/* ─────────────────────── detail view ─────────────────────── */ + +function ProjectDetail({ + project, + items, + onBack, +}: { + project: ProjectRow; + items: Item[] | undefined; + onBack: () => void; +}) { + const [editing, setEditing] = useState(false); + const projectItems = + items?.filter((it) => it.projectId === project._id) ?? []; + const projectStats = items ? computeStats(projectItems) : null; + const noEventsYet = items && projectItems.length === 0; + + return ( + <> +
+
+ +

{project.name}

+
+ {project.urlPatterns.length === 0 ? ( + no url patterns + ) : ( + project.urlPatterns.join(" · ") + )} +
+
+ {!editing && ( + + )} +
+ + {editing && ( + setEditing(false)} + onSaved={() => setEditing(false)} + onDeleted={onBack} + /> + )} + + {projectStats && ( +
+ + ITEMS // ALL TIME + + } + value={projectStats.total} + caption={`${projectStats.last7d} in the last 7 days`} + /> + + PRS DRAFTED // ALL TIME + + } + value={projectStats.prsOpened} + caption={`${projectStats.prsLast7d} in the last 7 days`} + accent + /> + + AWAITING // SLACK QUEUE + + } + value={projectStats.queued} + caption={ + projectStats.queued === 0 ? "all clear" : "needs review" + } + /> +
+ )} + + {noEventsYet && ( +
+

install the widget

+

+ otto isn't seeing events for this project yet. drop the snippet + below into your app and otto will route feedback here whenever + the page url matches one of your patterns. +

+

+ grab the snippet (with your team secret baked in) from{" "} + settings → drop the widget. +

+
+ )} + +
+ ACTIVITY // RECENT ITEMS +
+ {!items ? ( +

loading…

+ ) : projectItems.length === 0 ? ( +
+

no items yet for this project.

+
+ ) : ( + + + + + + + + + + + {projectItems.slice(0, 50).map((it) => ( + + + + + + + ))} + +
statuswhat otto's doingsourcepr
+ + {statusLabel(it.status)} + + {it.description} + {shortSource(it.sourceRef)} + + {it.prUrl ? ( + + open + + ) : ( + + )} +
+ )} + + ); +} + +/* ─────────────────────── create modal ─────────────────────── */ + +// Minimal create flow: just the project name. URL patterns, description, +// primary repo, and enabled-toggle are all configurable inside the +// project once it exists. Slug auto-derives from name. +function CreateProjectModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (id: Id<"projects">) => void; +}) { + const { teamId } = useTeam(); + const upsert = useMutation(api.projects.upsert); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + // Esc to close. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const onCreate = async () => { + if (!teamId || !name.trim()) return; + setBusy(true); + setErr(null); + try { + const id = await upsert({ + teamId, + name: name.trim(), + slug: name.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-"), + description: "", + urlPatterns: [], + primaryRepoId: null, + enabled: true, + }); + onCreated(id as Id<"projects">); + } catch (e) { + setErr(e instanceof Error ? e.message : "failed to create"); + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +

new project

+

+ give it a name. you can wire up a repo and url patterns once + it's created. +

+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim() && !busy) onCreate(); + }} + style={{ width: "100%" }} + /> + {err && ( +

+ {err} +

+ )} +
+ + +
+
+
+ ); +} + +/* ─────────────────────── edit form ─────────────────────── */ + +// The full project form, shown inline inside ProjectDetail. Lets you +// rename, edit url patterns, swap primary repo, toggle enabled, delete. +function ProjectEditForm({ + existing, + onCancel, + onSaved, + onDeleted, +}: { + existing: ProjectRow; + onCancel: () => void; + onSaved: () => void; + onDeleted: () => void; +}) { + const { teamId } = useTeam(); const repos = useQuery( api.reposDb.list, teamId ? { teamId } : "skip", ) as Doc<"repos">[] | undefined; const upsert = useMutation(api.projects.upsert); const remove = useMutation(api.projects.remove); - const setRepoProject = useMutation(api.projects.setRepoProject); - const [draft, setDraft] = useState(BLANK_DRAFT); - const [editing, setEditing] = useState | null>(null); + const [draft, setDraft] = useState({ + name: existing.name, + slug: existing.slug, + description: existing.description, + urlPatterns: existing.urlPatterns.join("\n"), + primaryRepoId: existing.primaryRepoId ?? "", + enabled: existing.enabled, + }); const onSave = async () => { if (!teamId) return; @@ -45,9 +532,11 @@ export function ProjectsTab() { .filter(Boolean); await upsert({ teamId, - id: editing ?? undefined, + id: existing._id, name: draft.name, - slug: draft.slug || draft.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-"), + slug: + draft.slug || + draft.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-"), description: draft.description, urlPatterns: patterns, primaryRepoId: draft.primaryRepoId @@ -55,265 +544,166 @@ export function ProjectsTab() { : null, enabled: draft.enabled, }); - setDraft(BLANK_DRAFT); - setEditing(null); + onSaved(); }; - const onEdit = (p: ProjectRow) => { - setEditing(p._id); - setDraft({ - name: p.name, - slug: p.slug, - description: p.description, - urlPatterns: p.urlPatterns.join("\n"), - primaryRepoId: p.primaryRepoId ?? "", - enabled: p.enabled, - }); - window.scrollTo({ top: 0, behavior: "smooth" }); + const onDelete = async () => { + if (!teamId) return; + if (!confirm(`delete project "${existing.name}"?`)) return; + await remove({ teamId, id: existing._id }); + onDeleted(); }; return ( - <> -
-

- {editing ? "edit project" : "add project"} -

-

- projects group repos by what they are for the team — orders, - customers, refunds. otto routes widget feedback to a project by - matching the page URL against its patterns. -

-
-
- - setDraft({ ...draft, name: e.target.value })} - style={{ width: "100%" }} - /> -
-
- - setDraft({ ...draft, slug: e.target.value })} - style={{ width: "100%" }} - /> -
+
+

edit project

+
+
+ + setDraft({ ...draft, name: e.target.value })} + style={{ width: "100%" }} + />
- -