@rightkit/social-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +40 -2
  5. package/dist/appName.d.ts +2 -0
  6. package/dist/appName.js +8 -0
  7. package/dist/components/ScopeSelect.d.ts +7 -0
  8. package/dist/components/ScopeSelect.js +6 -0
  9. package/dist/components/Shared.d.ts +34 -0
  10. package/dist/components/Shared.js +55 -0
  11. package/dist/data/derive.d.ts +21 -0
  12. package/dist/data/derive.js +47 -0
  13. package/dist/data/draftRules.d.ts +10 -0
  14. package/dist/data/draftRules.js +20 -0
  15. package/dist/data/engine/engineDataSource.d.ts +194 -0
  16. package/dist/data/engine/engineDataSource.js +836 -0
  17. package/dist/data/engine/errors.d.ts +13 -0
  18. package/dist/data/engine/errors.js +21 -0
  19. package/dist/data/engine/mapping.d.ts +172 -0
  20. package/dist/data/engine/mapping.js +1487 -0
  21. package/dist/data/engine/protocol.d.ts +910 -0
  22. package/dist/data/engine/protocol.js +67 -0
  23. package/dist/data/index.d.ts +18 -0
  24. package/dist/data/index.js +23 -0
  25. package/dist/data/mock/devSource.d.ts +7 -0
  26. package/dist/data/mock/devSource.js +10 -0
  27. package/dist/data/mock/fixtures-analytics.d.ts +7 -0
  28. package/dist/data/mock/fixtures-analytics.js +123 -0
  29. package/dist/data/mock/fixtures-briefs.d.ts +4 -0
  30. package/dist/data/mock/fixtures-briefs.js +230 -0
  31. package/dist/data/mock/fixtures-content.d.ts +4 -0
  32. package/dist/data/mock/fixtures-content.js +89 -0
  33. package/dist/data/mock/fixtures-digests.d.ts +3 -0
  34. package/dist/data/mock/fixtures-digests.js +40 -0
  35. package/dist/data/mock/fixtures-drafts.d.ts +2 -0
  36. package/dist/data/mock/fixtures-drafts.js +74 -0
  37. package/dist/data/mock/fixtures-gate.d.ts +3 -0
  38. package/dist/data/mock/fixtures-gate.js +49 -0
  39. package/dist/data/mock/fixtures-hooks.d.ts +2 -0
  40. package/dist/data/mock/fixtures-hooks.js +103 -0
  41. package/dist/data/mock/fixtures-inbox.d.ts +11 -0
  42. package/dist/data/mock/fixtures-inbox.js +213 -0
  43. package/dist/data/mock/fixtures-links.d.ts +5 -0
  44. package/dist/data/mock/fixtures-links.js +89 -0
  45. package/dist/data/mock/fixtures-memory.d.ts +3 -0
  46. package/dist/data/mock/fixtures-memory.js +148 -0
  47. package/dist/data/mock/fixtures-ops.d.ts +15 -0
  48. package/dist/data/mock/fixtures-ops.js +403 -0
  49. package/dist/data/mock/fixtures-optimise.d.ts +18 -0
  50. package/dist/data/mock/fixtures-optimise.js +221 -0
  51. package/dist/data/mock/fixtures-pacing.d.ts +5 -0
  52. package/dist/data/mock/fixtures-pacing.js +285 -0
  53. package/dist/data/mock/fixtures-publish.d.ts +2 -0
  54. package/dist/data/mock/fixtures-publish.js +129 -0
  55. package/dist/data/mock/fixtures-store.d.ts +10 -0
  56. package/dist/data/mock/fixtures-store.js +13 -0
  57. package/dist/data/mock/fixtures-variants.d.ts +5 -0
  58. package/dist/data/mock/fixtures-variants.js +138 -0
  59. package/dist/data/mock/fixtures.d.ts +6 -0
  60. package/dist/data/mock/fixtures.js +420 -0
  61. package/dist/data/mock/mockDataSource.d.ts +98 -0
  62. package/dist/data/mock/mockDataSource.js +903 -0
  63. package/dist/data/money.d.ts +14 -0
  64. package/dist/data/money.js +36 -0
  65. package/dist/data/platforms.d.ts +12 -0
  66. package/dist/data/platforms.js +26 -0
  67. package/dist/data/source.d.ts +196 -0
  68. package/dist/data/source.js +1 -0
  69. package/dist/data/time.d.ts +23 -0
  70. package/dist/data/time.js +86 -0
  71. package/dist/data/types.d.ts +1092 -0
  72. package/dist/data/types.js +3 -0
  73. package/dist/env.d.ts +59 -0
  74. package/dist/hooks/useAsync.d.ts +19 -0
  75. package/dist/hooks/useAsync.js +25 -0
  76. package/dist/index.d.ts +56 -0
  77. package/dist/index.js +58 -0
  78. package/dist/lib/host.d.ts +21 -0
  79. package/dist/lib/host.js +32 -0
  80. package/dist/lib/shell.d.ts +156 -0
  81. package/dist/lib/shell.js +16 -0
  82. package/dist/screens/AccountsScreen.d.ts +8 -0
  83. package/dist/screens/AccountsScreen.js +100 -0
  84. package/dist/screens/AnalyticsScreen.d.ts +7 -0
  85. package/dist/screens/AnalyticsScreen.js +64 -0
  86. package/dist/screens/ApprovalsScreen.d.ts +10 -0
  87. package/dist/screens/ApprovalsScreen.js +144 -0
  88. package/dist/screens/BriefsScreen.d.ts +7 -0
  89. package/dist/screens/BriefsScreen.js +83 -0
  90. package/dist/screens/CalendarScreen.d.ts +10 -0
  91. package/dist/screens/CalendarScreen.js +143 -0
  92. package/dist/screens/CampaignsScreen.d.ts +7 -0
  93. package/dist/screens/CampaignsScreen.js +52 -0
  94. package/dist/screens/ChannelMemoryScreen.d.ts +7 -0
  95. package/dist/screens/ChannelMemoryScreen.js +109 -0
  96. package/dist/screens/ContentScreen.d.ts +7 -0
  97. package/dist/screens/ContentScreen.js +64 -0
  98. package/dist/screens/DigestsScreen.d.ts +7 -0
  99. package/dist/screens/DigestsScreen.js +53 -0
  100. package/dist/screens/DraftsScreen.d.ts +10 -0
  101. package/dist/screens/DraftsScreen.js +108 -0
  102. package/dist/screens/FailuresScreen.d.ts +8 -0
  103. package/dist/screens/FailuresScreen.js +111 -0
  104. package/dist/screens/HookTestsScreen.d.ts +7 -0
  105. package/dist/screens/HookTestsScreen.js +54 -0
  106. package/dist/screens/InboxScreen.d.ts +12 -0
  107. package/dist/screens/InboxScreen.js +76 -0
  108. package/dist/screens/LinksScreen.d.ts +7 -0
  109. package/dist/screens/LinksScreen.js +52 -0
  110. package/dist/screens/PacingScreen.d.ts +7 -0
  111. package/dist/screens/PacingScreen.js +88 -0
  112. package/dist/screens/PublishJobsScreen.d.ts +7 -0
  113. package/dist/screens/PublishJobsScreen.js +66 -0
  114. package/dist/screens/SuggestionsScreen.d.ts +8 -0
  115. package/dist/screens/SuggestionsScreen.js +94 -0
  116. package/dist/screens/TextGateScreen.d.ts +7 -0
  117. package/dist/screens/TextGateScreen.js +111 -0
  118. package/dist/screens/ThreadsScreen.d.ts +8 -0
  119. package/dist/screens/ThreadsScreen.js +113 -0
  120. package/dist/screens/TypedBriefsScreen.d.ts +7 -0
  121. package/dist/screens/TypedBriefsScreen.js +75 -0
  122. package/dist/screens/labels.d.ts +121 -0
  123. package/dist/screens/labels.js +272 -0
  124. package/package.json +56 -3
@@ -0,0 +1,64 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { EmptyState, Field, PageShell, SkeletonRows, Table, TableBody, TableCaption, TableCell, TableHead, TableHeaderCell, TableRow } from "@rightkit/react-shell/react";
4
+ import { ScopeSelect } from "../components/ScopeSelect.js";
5
+ import { LoadError, MockBanner } from "../components/Shared.js";
6
+ import { useAsync } from "../hooks/useAsync.js";
7
+ import { groupDigits, instantLabel, } from "../data/index.js";
8
+ import { ANALYTICS_UNAVAILABLE, analyticsSourceText, platformName, postAgeText } from "./labels.js";
9
+ export function AnalyticsScreen({ source, rev }) {
10
+ const scopes = useAsync(() => source.listScopes(), [source, rev]);
11
+ const [picked, setPicked] = useState(null);
12
+ const scopeList = scopes.value ?? [];
13
+ const scopeId = scopeList.find((s) => s.id === picked)?.id ?? scopeList[0]?.id ?? null;
14
+ return (_jsxs(PageShell, { title: "Analytics", subtitle: "Read only. The numbers stored for each published post of one channel: the latest value of each metric with its age and source, the observation history of one post, and the manual imports. Nothing here pulls, imports, ranks, averages or compares numbers.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, scopes.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the channels from the daemon.", onRetry: scopes.reload })) : scopes.value === null ? (_jsx(SkeletonRows, { label: "Loading channels", rows: 2 })) : scopeList.length === 0 || scopeId === null ? (_jsx(EmptyState, { title: "No channels yet", children: _jsx("p", { children: "Analytics is kept per channel once one is set up." }) })) : (_jsxs(_Fragment, { children: [_jsx(ScopeSelect, { scopes: scopeList, value: scopeId, onChange: (id) => setPicked(id) }), _jsx(AnalyticsView, { source: source, rev: rev, scopeId: scopeId }, scopeId)] }))] }));
15
+ }
16
+ function AnalyticsView({ source, rev, scopeId }) {
17
+ const posts = useAsync(() => source.listAnalyticsPosts(scopeId), [source, rev, scopeId]);
18
+ const imports = useAsync(() => source.listMetricImports(scopeId), [source, rev, scopeId]);
19
+ const [picked, setPicked] = useState(null);
20
+ if (posts.state.status === "error") {
21
+ return _jsx(LoadError, { message: "Could not read the published posts from the daemon.", onRetry: posts.reload });
22
+ }
23
+ if (imports.state.status === "error") {
24
+ return _jsx(LoadError, { message: "Could not read the manual imports from the daemon.", onRetry: imports.reload });
25
+ }
26
+ if (posts.state.status === "loading" || imports.state.status === "loading") {
27
+ return _jsx(SkeletonRows, { label: "Loading analytics", rows: 3 });
28
+ }
29
+ const postList = posts.state.value;
30
+ const importList = imports.state.value;
31
+ if (postList.length === 0 && importList.length === 0) {
32
+ return (_jsx(EmptyState, { title: "No numbers yet", children: _jsx("p", { children: "No numbers have been pulled or imported for this channel yet. This is not an error." }) }));
33
+ }
34
+ const selected = postList.find((p) => p.jobId === picked) ?? postList[0];
35
+ return (_jsxs(_Fragment, { children: [_jsx("p", { className: "op-hint", children: "Numbers are pulled on the server (the daemon), not from this screen. This screen pulls nothing and imports nothing. Each value is shown exactly as stored. A metric the platform does not provide says so, and is never shown as zero." }), _jsxs("section", { "aria-labelledby": "analytics-latest-title", children: [_jsxs("h2", { id: "analytics-latest-title", className: "op-section-title", children: ["Published posts (", groupDigits(String(postList.length)), ")"] }), postList.length === 0 ? (_jsx("p", { className: "op-hint", children: "No published post has a number stored yet." })) : (_jsx(LatestTable, { posts: postList }))] }), postList.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "analytics-history-title", children: [_jsx("h2", { id: "analytics-history-title", className: "op-section-title", children: "Observation history" }), _jsx("p", { className: "op-hint", children: "Every stored observation of the chosen post, oldest first. Each row names the pull or the import that stored it." }), _jsx(Field, { label: "Post", children: ({ id, "aria-describedby": describedBy }) => (_jsx("select", { id: id, "aria-describedby": describedBy, className: "op-select", value: selected.jobId, onChange: (e) => setPicked(e.target.value), children: postList.map((post) => (_jsxs("option", { value: post.jobId, children: [platformName(post.platform), " post ", post.remotePostId] }, post.jobId))) })) }), _jsx(PostHistory, { source: source, rev: rev, scopeId: scopeId, jobId: selected.jobId }, selected.jobId)] })), _jsx(ImportsSection, { imports: importList })] }));
36
+ }
37
+ /** The latest value of each metric of each post. A post with no metric says so in its own row. */
38
+ function LatestTable({ posts }) {
39
+ return (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "analytics-latest-title", children: [_jsx(TableCaption, { children: "Latest stored value of each metric, by published post" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Post" }), _jsx(TableHeaderCell, { children: "Metric" }), _jsx(TableHeaderCell, { children: "Window" }), _jsx(TableHeaderCell, { align: "end", children: "Latest value" }), _jsx(TableHeaderCell, { children: "Observed (UTC)" }), _jsx(TableHeaderCell, { children: "Age of the post at that observation" }), _jsx(TableHeaderCell, { children: "Source" })] }) }), _jsx(TableBody, { children: posts.map((post) => (_jsx(PostRows, { post: post }, `post:${post.jobId}`))) })] }) }));
40
+ }
41
+ function PostRows({ post }) {
42
+ const head = (_jsxs(TableCell, { rowHeader: true, rowSpan: Math.max(post.metrics.length, 1), children: [_jsxs("div", { children: [platformName(post.platform), ", post ", _jsx("span", { className: "op-mono", children: post.remotePostId })] }), _jsxs("div", { children: ["Published (UTC): ", instantLabel(post.publishedAtUtc, "UTC")] })] }));
43
+ if (post.metrics.length === 0) {
44
+ return (_jsxs(TableRow, { children: [head, _jsx(TableCell, { colSpan: 6, children: "No number is stored for this post yet." })] }));
45
+ }
46
+ return (_jsx(_Fragment, { children: post.metrics.map((metric, index) => (_jsxs(TableRow, { children: [index === 0 ? head : null, _jsx(TableCell, { className: "op-mono", children: metric.metric }), _jsx(TableCell, { children: metric.window ?? "No window" }), metric.state === "value" ? (_jsx(TableCell, { numeric: true, className: "op-mono", children: metric.value })) : (_jsx(TableCell, { children: ANALYTICS_UNAVAILABLE })), _jsx(TableCell, { className: "op-mono", children: instantLabel(metric.observedAtUtc, "UTC") }), _jsx(TableCell, { className: "op-mono", children: postAgeText(metric.postAgeSeconds) }), _jsx(TableCell, { children: analyticsSourceText(metric.source, metric.importId) })] }, `metric:${metric.metric}:${metric.window ?? "no window"}:${metric.source}`))) }));
47
+ }
48
+ /** Every stored observation of one post, oldest first, with the pull or import that stored it. */
49
+ function PostHistory({ source, rev, scopeId, jobId, }) {
50
+ const history = useAsync(() => source.listAnalyticsObservations(scopeId, jobId), [source, rev, scopeId, jobId]);
51
+ if (history.state.status === "error") {
52
+ return _jsx(LoadError, { message: "Could not read the observation history from the daemon.", onRetry: history.reload });
53
+ }
54
+ if (history.state.status === "loading") {
55
+ return _jsx(SkeletonRows, { label: "Loading the observation history", rows: 3 });
56
+ }
57
+ const rows = history.state.value;
58
+ if (rows.length === 0)
59
+ return _jsx("p", { className: "op-hint", children: "No observation is stored for this post." });
60
+ return (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "analytics-history-title", children: [_jsx(TableCaption, { children: "Every stored observation of this post, oldest first" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Observed (UTC)" }), _jsx(TableHeaderCell, { children: "Metric" }), _jsx(TableHeaderCell, { children: "Window" }), _jsx(TableHeaderCell, { align: "end", children: "Value" }), _jsx(TableHeaderCell, { children: "Source" }), _jsx(TableHeaderCell, { children: "Pull or import" })] }) }), _jsx(TableBody, { children: rows.map((row, index) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: instantLabel(row.observedAtUtc, "UTC") }), _jsx(TableCell, { className: "op-mono", children: row.metric }), _jsx(TableCell, { children: row.window ?? "No window" }), row.state === "value" ? (_jsx(TableCell, { numeric: true, className: "op-mono", children: row.value })) : (_jsx(TableCell, { children: ANALYTICS_UNAVAILABLE })), _jsx(TableCell, { children: analyticsSourceText(row.source, row.importId) }), _jsx(TableCell, { className: "op-mono", children: row.importId !== null ? `Import ${row.importId}` : row.pullId !== null ? `Pull ${row.pullId}` : "Not stated" })] }, `observation:${index}`))) })] }) }));
61
+ }
62
+ function ImportsSection({ imports }) {
63
+ return (_jsxs("section", { "aria-labelledby": "analytics-imports-title", children: [_jsxs("h2", { id: "analytics-imports-title", className: "op-section-title", children: ["Manual imports (", groupDigits(String(imports.length)), ")"] }), _jsx("p", { className: "op-hint", children: "A manual import is a file the channel owner exported by hand and the daemon read. Its values appear in the tables above, with the import id. This screen imports nothing." }), imports.length === 0 ? (_jsx("p", { className: "op-hint", children: "No manual import is stored for this channel." })) : (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "analytics-imports-title", children: [_jsx(TableCaption, { children: "Manual imports in this channel, by import id" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Import id" }), _jsx(TableHeaderCell, { children: "Platform" }), _jsx(TableHeaderCell, { align: "end", children: "Rows" }), _jsx(TableHeaderCell, { align: "end", children: "Values" }), _jsx(TableHeaderCell, { children: "Source note" }), _jsx(TableHeaderCell, { children: "Imported (UTC)" })] }) }), _jsx(TableBody, { children: imports.map((record) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: record.importId }), _jsx(TableCell, { children: platformName(record.platform) }), _jsx(TableCell, { numeric: true, children: groupDigits(String(record.rowsWritten)) }), _jsx(TableCell, { numeric: true, children: groupDigits(String(record.observationsWritten)) }), _jsx(TableCell, { children: record.sourceNote }), _jsx(TableCell, { className: "op-mono", children: instantLabel(record.createdAtUtc, "UTC") })] }, `import:${record.importId}`))) })] }) }))] }));
64
+ }
@@ -0,0 +1,10 @@
1
+ import { type DataSource } from "../data/index.js";
2
+ interface Props {
3
+ source: DataSource;
4
+ rev: number;
5
+ /** Called after any write so the app re-reads counts and every screen. */
6
+ onChanged: () => void;
7
+ initialJobId: string | null;
8
+ }
9
+ export declare function ApprovalsScreen({ source, rev, onChanged, initialJobId }: Props): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,144 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Badge, Button, ConfirmDialog, Field, ModalShell, Notice, PageShell, SegmentedControl, SkeletonRows } from "@rightkit/react-shell/react";
4
+ import { AccountChip, LoadError, MockBanner, PublishProgressView } from "../components/Shared.js";
5
+ import { useAsync } from "../hooks/useAsync.js";
6
+ import { accountOrdinals, awaitingDecision, charCount, formatCount, instantLabel, isApproved, mediaReady, shortHash, } from "../data/index.js";
7
+ import { FORMAT_LABEL, MEDIA_STATUS_LABEL, PLATFORM_LABEL, REJECT_LABEL, STATE_LABEL, approvalText } from "./labels.js";
8
+ const WIDE_RANGE = { fromUtc: "2026-01-01T00:00:00Z", toUtc: "2027-01-01T00:00:00Z" };
9
+ const REJECT_ORDER = ["wrong_content", "wrong_account", "timing", "media_issue", "other"];
10
+ /** Formats whole milliseconds as m:ss. Fractional seconds are dropped, so 59999 ms reads 0:59. */
11
+ function duration(ms) {
12
+ if (ms === null)
13
+ return "still image";
14
+ const total = Math.floor(ms / 1000);
15
+ const m = Math.floor(total / 60);
16
+ const s = String(total % 60).padStart(2, "0");
17
+ return `${m}:${s}`;
18
+ }
19
+ /** Why approve is unavailable, in words. Null when approve can be pressed. */
20
+ function approveBlock(job) {
21
+ switch (job.approval.status) {
22
+ case "approved":
23
+ return `Already approved at revision ${job.approval.approvedRevision}. Any change to the caption, media or policy invalidates it.`;
24
+ case "rejected":
25
+ return "Rejected. Revise the brief to bring it back for approval.";
26
+ case "none":
27
+ return "No approval requested yet for this job.";
28
+ default:
29
+ break;
30
+ }
31
+ if (!mediaReady(job))
32
+ return "Media not ready on server, so approval is disabled until every item is ready.";
33
+ return null;
34
+ }
35
+ export function ApprovalsScreen({ source, rev, onChanged, initialJobId }) {
36
+ const [filter, setFilter] = useState("decision");
37
+ const [selectedId, setSelectedId] = useState(initialJobId);
38
+ const [confirmApprove, setConfirmApprove] = useState(false);
39
+ const [rejecting, setRejecting] = useState(false);
40
+ const [notice, setNotice] = useState(null);
41
+ const [busy, setBusy] = useState(false);
42
+ useEffect(() => {
43
+ setNotice(null);
44
+ }, [selectedId]);
45
+ const list = useAsync(async () => {
46
+ const [accounts, jobs] = await Promise.all([source.listAccounts(), source.listJobs(WIDE_RANGE)]);
47
+ return { accounts, jobs };
48
+ }, [source, rev]);
49
+ // The preview always reads the job fresh. Approve and reject use the revision on screen, so an
50
+ // approval never rests on a stale snapshot.
51
+ const preview = useAsync(() => (selectedId ? source.getJob(selectedId) : Promise.resolve(null)), [source, selectedId, rev]);
52
+ // list.value keeps the last good data during a reload, so the preview never blanks mid-approve.
53
+ const accounts = list.value?.accounts ?? [];
54
+ const allJobs = list.value?.jobs ?? [];
55
+ const ordinals = useMemo(() => accountOrdinals(accounts), [accounts]);
56
+ const accountById = useMemo(() => new Map(accounts.map((a) => [a.id, a])), [accounts]);
57
+ const decisionCount = allJobs.filter(awaitingDecision).length;
58
+ const filtered = allJobs.filter((job) => {
59
+ if (filter === "decision")
60
+ return awaitingDecision(job);
61
+ if (filter === "approved")
62
+ return isApproved(job);
63
+ return true;
64
+ });
65
+ // Jobs whose account is not in the accounts list. They are listed on their own, not dropped.
66
+ const orphans = filtered.filter((job) => !accountById.has(job.accountId));
67
+ const job = preview.state.status === "ready" ? preview.state.value : null;
68
+ const account = job ? accountById.get(job.accountId) ?? null : null;
69
+ const ordinal = account ? ordinals.get(account.id) : undefined;
70
+ const destination = account ? `${PLATFORM_LABEL[account.platform]} ${account.handle}` : "this account";
71
+ async function approve(current) {
72
+ setConfirmApprove(false);
73
+ setBusy(true);
74
+ const result = await source.approve(current.id, current.revision, current.accountId);
75
+ setBusy(false);
76
+ setNotice(result.ok
77
+ ? { tone: "ok", text: `Approved revision ${current.revision} for ${destination}. It is scheduled.` }
78
+ : { tone: "bad", text: result.error.message });
79
+ onChanged();
80
+ }
81
+ async function reject(current, code, note) {
82
+ setRejecting(false);
83
+ setBusy(true);
84
+ const result = await source.reject(current.id, {
85
+ revision: current.revision,
86
+ accountId: current.accountId,
87
+ code,
88
+ note,
89
+ });
90
+ setBusy(false);
91
+ setNotice(result.ok
92
+ ? { tone: "ok", text: `Rejected for ${destination}. Reason: ${REJECT_LABEL[code]}. Revision ${result.value.revision}.` }
93
+ : { tone: "bad", text: result.error.message });
94
+ onChanged();
95
+ }
96
+ async function simulateEdit(current) {
97
+ if (!source.mock)
98
+ return;
99
+ const edited = `${current.caption}\n\nEdited on another device: one more line for the community.`;
100
+ const result = await source.mock.simulateCaptionChange(current.id, edited);
101
+ setNotice(result.ok
102
+ ? { tone: "bad", text: "The caption changed on another device. Any approval for the old text is now invalid." }
103
+ : { tone: "bad", text: result.error.message });
104
+ onChanged();
105
+ }
106
+ /** One job's row in the inbox. A job whose account is not in the accounts list also shows its raw account id. */
107
+ function jobRow(row) {
108
+ const approval = approvalText(row.approval);
109
+ return (_jsx("li", { children: _jsxs(Button, { type: "button", variant: "secondary", className: "op-row", "aria-current": row.id === selectedId ? "true" : undefined, onClick: () => setSelectedId(row.id), children: [_jsx("span", { className: "op-row__time", children: instantLabel(row.scheduledAtUtc, row.timeZone) }), _jsxs("span", { className: "op-row__meta", children: [FORMAT_LABEL[row.format], " \u00B7 ", STATE_LABEL[row.state].text, accountById.has(row.accountId) ? null : (_jsxs(_Fragment, { children: [" ", "\u00B7 account id ", _jsx("span", { className: "op-mono", children: row.accountId })] }))] }), _jsx(Badge, { tone: approval.tone, children: approval.text })] }) }, row.id));
110
+ }
111
+ const canApprove = job !== null && awaitingDecision(job) && mediaReady(job);
112
+ const canReject = job !== null && (awaitingDecision(job) || isApproved(job));
113
+ return (_jsxs(PageShell, { title: "Approvals", subtitle: "The only place content becomes publishable. An approval binds one exact revision of one job.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, _jsx("div", { className: "op-inbox__filters", children: _jsx(SegmentedControl, { label: "Show", value: filter, onChange: setFilter, options: [
114
+ { value: "decision", label: `Needs decision (${decisionCount})` },
115
+ { value: "approved", label: "Approved" },
116
+ { value: "all", label: "All" },
117
+ ] }) }), _jsxs("div", { className: "op-inbox", children: [_jsxs("section", { className: "op-inbox__list", "aria-labelledby": "inbox-list-title", children: [_jsx("h2", { id: "inbox-list-title", className: "op-section-title", children: "Jobs" }), list.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the approval inbox from the daemon.", onRetry: list.reload })) : list.state.status === "loading" && allJobs.length === 0 ? (_jsx(SkeletonRows, { label: "Loading jobs", rows: 4 })) : filtered.length === 0 ? (_jsx("p", { className: "op-hint", children: "Nothing in this view." })) : (_jsxs(_Fragment, { children: [accounts.map((a) => {
118
+ const rows = filtered.filter((j) => j.accountId === a.id);
119
+ if (rows.length === 0)
120
+ return null;
121
+ return (_jsxs("section", { className: "op-inbox__group", "aria-label": `${ordinals.get(a.id)?.label ?? "Account"}, ${a.handle}`, children: [_jsx("div", { className: "op-inbox__group-head", children: _jsx(AccountChip, { account: a, ordinal: ordinals.get(a.id) }) }), _jsx("ul", { children: rows.map((row) => jobRow(row)) })] }, a.id));
122
+ }), orphans.length > 0 ? (_jsxs("section", { className: "op-inbox__group", "aria-label": "Jobs whose account is not in the accounts list", children: [_jsx("div", { className: "op-inbox__group-head", children: _jsx("p", { className: "op-hint", children: "Account not found in the accounts list" }) }), _jsx("ul", { children: orphans.map((row) => jobRow(row)) })] })) : null] }))] }), _jsxs("section", { className: "op-inbox__preview", "aria-label": "Variant preview", children: [selectedId === null ? (_jsx("p", { className: "op-hint", children: "Select a job to see the exact variant that would publish." })) : preview.state.status === "error" ? (_jsx(LoadError, { message: "Could not load this job.", onRetry: preview.reload })) : preview.state.status === "loading" ? (_jsx(SkeletonRows, { label: "Fetching the job and its media by digest", rows: 5 })) : job !== null && account === null ? (_jsxs(Notice, { tone: "bad", children: ["Account not found in the accounts list (account id ", _jsx("span", { className: "op-mono", children: job.accountId }), "). Nothing was changed."] })) : job === null || account === null || ordinal === undefined ? (_jsx(Notice, { tone: "bad", children: "This job is no longer available. Nothing was changed." })) : (_jsxs("article", { className: "op-preview", "aria-labelledby": "preview-title", children: [_jsxs("header", { className: `op-preview__account op-band-${account.band}`, children: [_jsx("p", { className: "op-kicker", children: "Destination account" }), _jsxs("h2", { id: "preview-title", className: "op-preview__title", children: [PLATFORM_LABEL[account.platform], ": ", account.displayName, " ", account.handle] }), _jsxs("p", { children: [ordinal.label, " of ", ordinal.of, " on ", PLATFORM_LABEL[account.platform], " \u00B7 ", account.venture.name, " \u00B7 id ending", " ", account.idSuffix] })] }), _jsxs("section", { "aria-labelledby": "pv-when", children: [_jsx("h3", { id: "pv-when", className: "op-section-title", children: "When" }), _jsx("p", { children: instantLabel(job.scheduledAtUtc, job.timeZone) }), job.dst.kind !== "none" ? _jsx("p", { children: job.dst.note }) : null] }), job.publish ? (_jsxs("section", { "aria-labelledby": "pv-publish", children: [_jsx("h3", { id: "pv-publish", className: "op-section-title", children: "Daemon publish" }), _jsx(PublishProgressView, { progress: job.publish, timeZone: job.timeZone })] })) : null, _jsxs("section", { "aria-labelledby": "pv-caption", children: [_jsx("h3", { id: "pv-caption", className: "op-section-title", children: "Caption as it will be posted" }), _jsxs("p", { className: "op-count", children: [formatCount(charCount(job.caption)), " characters \u00B7 fingerprint", " ", _jsx("code", { children: job.captionDigest })] }), _jsx("div", { className: "op-caption", tabIndex: 0, role: "region", "aria-label": "Caption text", children: job.caption })] }), _jsxs("section", { "aria-labelledby": "pv-comment", children: [_jsx("h3", { id: "pv-comment", className: "op-section-title", children: "First comment or link" }), _jsx("p", { children: job.firstComment ?? "None" })] }), _jsxs("section", { "aria-labelledby": "pv-media", children: [_jsx("h3", { id: "pv-media", className: "op-section-title", children: "Media" }), _jsx("ul", { className: "op-media", children: job.mediaItems.map((m, index) => (_jsxs("li", { children: [_jsx("span", { className: "op-mono", children: shortHash(m.hash) }), " ", m.kind === "video" ? "Video" : "Image", " \u00B7", " ", duration(m.durationMs !== undefined ? m.durationMs : m.durationSeconds === null ? null : Math.round(m.durationSeconds * 1000)), m.dimensions ? ` · ${m.dimensions}` : "", " \u00B7 ", MEDIA_STATUS_LABEL[m.status], m.status === "uploading" && m.uploadPercent !== null ? ` ${m.uploadPercent}%` : "", _jsxs("details", { children: [_jsx("summary", { children: "Full digest" }), _jsx("code", { className: "op-digest", children: m.hash })] })] }, `${m.hash}#${index}`))) })] }), _jsxs("section", { "aria-labelledby": "pv-settings", children: [_jsx("h3", { id: "pv-settings", className: "op-section-title", children: "Settings" }), _jsx("ul", { children: job.settings.map((s, index) => (_jsx("li", { children: s }, `${s}#${index}`))) })] }), _jsxs("section", { "aria-labelledby": "pv-policy", children: [_jsx("h3", { id: "pv-policy", className: "op-section-title", children: "Policy and revision" }), _jsxs("p", { children: ["Policy ", job.policyRevision === null ? "revision not stated" : `r${job.policyRevision}`, ". Approving revision ", job.revision, ". Job state: ", STATE_LABEL[job.state].text, "."] }), _jsx("p", { children: _jsx(Badge, { tone: approvalText(job.approval).tone, children: approvalText(job.approval).text }) }), job.approval.status === "invalidated" ? (_jsxs("p", { role: "status", className: "op-invalid", children: ["The caption changed after revision ", job.approval.approvedRevision, " was approved. This job will not publish until revision ", job.revision, " is approved."] })) : null, job.approval.status === "rejected" && job.approval.note ? _jsxs("p", { children: ["Note: ", job.approval.note] }) : null, job.state === "outcome_unknown" ? (_jsx("p", { role: "alert", className: "op-invalid", children: "The platform may have accepted this post. No retry until reconciled." })) : null] }), _jsxs("section", { className: "op-decide", "aria-labelledby": "pv-decide", children: [_jsx("h3", { id: "pv-decide", className: "op-section-title", children: "Decision" }), approveBlock(job) ? _jsx("p", { className: "op-hint", children: approveBlock(job) }) : null, _jsxs("div", { className: "op-decide__buttons", children: [_jsx(Button, { variant: "primary", disabled: !canApprove || busy, onClick: () => setConfirmApprove(true), children: `Approve for ${account.handle} on ${PLATFORM_LABEL[account.platform]}` }), _jsx(Button, { variant: "danger", disabled: !canReject || busy, onClick: () => setRejecting(true), children: `Reject for ${account.handle} on ${PLATFORM_LABEL[account.platform]}` }), source.mock ? (_jsx(Button, { variant: "secondary", disabled: busy, onClick: () => simulateEdit(job), children: "Simulate an edit from another device" })) : null] })] })] })), _jsx("div", { "aria-live": "polite", children: notice ? (_jsx(Notice, { tone: notice.tone, className: "op-notice", children: notice.text })) : null })] })] }), confirmApprove && job && account && ordinal ? (_jsx(ConfirmDialog, { title: `Approve revision ${job.revision} for ${account.handle} on ${PLATFORM_LABEL[account.platform]}?`, description: _jsxs(_Fragment, { children: [_jsxs("span", { className: "op-dialog-line", children: ["Account: ", account.displayName, " (", account.handle, "), ", ordinal.label.toLowerCase(), " of ", ordinal.of, " on", " ", PLATFORM_LABEL[account.platform], "."] }), _jsxs("span", { className: "op-dialog-line", children: ["It publishes ", instantLabel(job.scheduledAtUtc, job.timeZone), ".", " ", job.policyRevision === null ? "Policy revision not stated." : `Policy r${job.policyRevision}.`] }), _jsx("span", { className: "op-dialog-line", children: "Any later change to the caption, media or policy invalidates this approval." })] }), confirmLabel: `Approve for ${account.handle} on ${PLATFORM_LABEL[account.platform]}`, cancelLabel: "Cancel", onConfirm: () => approve(job), onCancel: () => setConfirmApprove(false) })) : null, rejecting && job && account ? (_jsx(RejectDialog, { handle: account.handle, platform: PLATFORM_LABEL[account.platform], onCancel: () => setRejecting(false), onSubmit: (code, note) => reject(job, code, note) })) : null] }));
123
+ }
124
+ function RejectDialog({ handle, platform, onSubmit, onCancel, }) {
125
+ const [code, setCode] = useState(null);
126
+ const [note, setNote] = useState("");
127
+ const [error, setError] = useState(null);
128
+ function submit(event) {
129
+ event.preventDefault();
130
+ if (code === null) {
131
+ setError("Choose a reason before rejecting.");
132
+ return;
133
+ }
134
+ if (code === "other" && note.trim() === "") {
135
+ setError("Add a note when the reason is Other.");
136
+ return;
137
+ }
138
+ onSubmit(code, note.trim());
139
+ }
140
+ return (_jsx(ModalShell, { label: `Reject for ${handle} on ${platform}`, onClose: onCancel, width: 480, role: "alertdialog", children: _jsxs("form", { className: "op-form", onSubmit: submit, noValidate: true, children: [_jsx("h2", { className: "op-section-title", children: `Reject for ${handle} on ${platform}` }), _jsxs("fieldset", { className: "op-fieldset", "aria-describedby": error ? "reject-error" : undefined, children: [_jsx("legend", { children: "Reason" }), REJECT_ORDER.map((r) => (_jsxs("label", { className: "op-radio", children: [_jsx("input", { type: "radio", name: "reject-reason", value: r, checked: code === r, onChange: () => {
141
+ setCode(r);
142
+ setError(null);
143
+ } }), REJECT_LABEL[r]] }, r)))] }), _jsx(Field, { label: "Note (optional, required for Other)", children: ({ id }) => (_jsx("textarea", { id: id, className: "op-textarea", rows: 3, value: note, onChange: (e) => setNote(e.target.value) })) }), error ? (_jsx("p", { id: "reject-error", role: "alert", className: "op-error-inline", children: error })) : null, _jsxs("div", { className: "op-dialog__actions", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "danger", children: `Reject for ${handle} on ${platform}` })] })] }) }));
144
+ }
@@ -0,0 +1,7 @@
1
+ import { type DataSource } from "../data/index.js";
2
+ interface Props {
3
+ source: DataSource;
4
+ rev: number;
5
+ }
6
+ export declare function BriefsScreen({ source, rev }: Props): import("react").JSX.Element;
7
+ export {};
@@ -0,0 +1,83 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Badge, EmptyState, Field, PageShell, SkeletonRows, Table, TableBody, TableCaption, TableCell, TableHead, TableHeaderCell, TableRow } from "@rightkit/react-shell/react";
4
+ import { ScopeSelect } from "../components/ScopeSelect.js";
5
+ import { LoadError, MockBanner } from "../components/Shared.js";
6
+ import { useAsync } from "../hooks/useAsync.js";
7
+ import { groupDigits, instantLabel, } from "../data/index.js";
8
+ import { BRIEF_STATE_LABEL, DISPATCH_OUTCOME_LABEL } from "./labels.js";
9
+ export function BriefsScreen({ source, rev }) {
10
+ const scopes = useAsync(() => source.listScopes(), [source, rev]);
11
+ const [picked, setPicked] = useState(null);
12
+ const scopeList = scopes.value ?? [];
13
+ const scopeId = scopeList.find((s) => s.id === picked)?.id ?? scopeList[0]?.id ?? null;
14
+ return (_jsxs(PageShell, { title: "Briefs and deliveries", subtitle: "Read only. The outgoing content briefs of one channel, the delivery envelope each engine returned, and every dispatch attempt. Nothing here issues a brief, accepts a delivery or runs an engine.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, scopes.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the channels from the daemon.", onRetry: scopes.reload })) : scopes.value === null ? (_jsx(SkeletonRows, { label: "Loading channels", rows: 2 })) : scopeList.length === 0 || scopeId === null ? (_jsx(EmptyState, { title: "No channels yet", children: _jsx("p", { children: "Outgoing briefs are kept per channel once one is set up." }) })) : (_jsxs(_Fragment, { children: [_jsx(ScopeSelect, { scopes: scopeList, value: scopeId, onChange: (id) => setPicked(id) }), _jsx(BriefsView, { source: source, rev: rev, scopeId: scopeId }, scopeId)] }))] }));
15
+ }
16
+ function BriefsView({ source, rev, scopeId }) {
17
+ const { state, reload, value } = useAsync(() => source.listBriefs(scopeId), [source, rev, scopeId]);
18
+ const [picked, setPicked] = useState(null);
19
+ if (state.status === "error") {
20
+ return _jsx(LoadError, { message: "Could not read the outgoing briefs from the daemon.", onRetry: reload });
21
+ }
22
+ if (value === null)
23
+ return _jsx(SkeletonRows, { label: "Loading outgoing briefs", rows: 3 });
24
+ if (value.length === 0) {
25
+ return (_jsx(EmptyState, { title: "No outgoing briefs yet", children: _jsx("p", { children: "This channel has no outgoing brief. A brief is issued on the daemon host or with the CLI, not here. This is not an error." }) }));
26
+ }
27
+ const selected = value.find((b) => b.requestId === picked) ?? value[0];
28
+ return (_jsxs(_Fragment, { children: [_jsxs("section", { "aria-labelledby": "briefs-list-title", children: [_jsxs("h2", { id: "briefs-list-title", className: "op-section-title", children: ["Outgoing briefs (", value.length, ")"] }), _jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "briefs-list-title", children: [_jsx(TableCaption, { children: "Outgoing briefs in this channel" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Request id" }), _jsx(TableHeaderCell, { children: "Brief id" }), _jsx(TableHeaderCell, { align: "end", children: "Revision" }), _jsx(TableHeaderCell, { children: "State" }), _jsx(TableHeaderCell, { children: "Created (UTC)" })] }) }), _jsx(TableBody, { children: value.map((brief) => {
29
+ const label = BRIEF_STATE_LABEL[brief.state];
30
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: brief.requestId }), _jsx(TableCell, { className: "op-mono", children: brief.briefId }), _jsx(TableCell, { numeric: true, children: groupDigits(String(brief.revision)) }), _jsx(TableCell, { children: _jsx(Badge, { tone: label.tone, children: label.text }) }), _jsx(TableCell, { className: "op-mono", children: instantLabel(brief.createdAtUtc, "UTC") })] }, brief.requestId));
31
+ }) })] }) })] }), _jsxs("section", { "aria-labelledby": "brief-detail-heading", children: [_jsx("h2", { id: "brief-detail-heading", className: "op-section-title", children: "Selected brief" }), _jsx(Field, { label: "Brief", children: ({ id, "aria-describedby": describedBy }) => (_jsx("select", { id: id, "aria-describedby": describedBy, className: "op-select", value: selected.requestId, onChange: (e) => setPicked(e.target.value), children: value.map((brief) => (_jsxs("option", { value: brief.requestId, children: [brief.requestId, " (", brief.briefId, ")"] }, brief.requestId))) })) }), _jsx(BriefDetail, { source: source, rev: rev, scopeId: scopeId, requestId: selected.requestId }, selected.requestId)] })] }));
32
+ }
33
+ function BriefDetail({ source, rev, scopeId, requestId, }) {
34
+ const brief = useAsync(() => source.getBrief(scopeId, requestId), [source, rev, scopeId, requestId]);
35
+ const runs = useAsync(() => source.listDispatchAttempts(scopeId, requestId), [source, rev, scopeId, requestId]);
36
+ const envelopes = useAsync(() => source.listDeliveries(scopeId), [source, rev, scopeId]);
37
+ if (brief.state.status === "error") {
38
+ return _jsx(LoadError, { message: "Could not read this brief from the daemon.", onRetry: brief.reload });
39
+ }
40
+ if (runs.state.status === "error") {
41
+ return _jsx(LoadError, { message: "Could not read the dispatch attempts from the daemon.", onRetry: runs.reload });
42
+ }
43
+ if (envelopes.state.status === "error") {
44
+ return _jsx(LoadError, { message: "Could not read the deliveries from the daemon.", onRetry: envelopes.reload });
45
+ }
46
+ if (brief.state.status === "loading" || runs.state.status === "loading" || envelopes.state.status === "loading") {
47
+ return _jsx(SkeletonRows, { label: "Loading the brief", rows: 3 });
48
+ }
49
+ const record = brief.state.value;
50
+ if (record === null)
51
+ return _jsx("p", { className: "op-hint", children: "This brief is no longer in this channel." });
52
+ const attempts = runs.state.value;
53
+ const requestEnvelopes = envelopes.state.value.filter((d) => d.requestId === requestId);
54
+ const state = BRIEF_STATE_LABEL[record.state];
55
+ const summary = record.summary;
56
+ return (_jsxs("article", { className: "op-detail", "aria-labelledby": "brief-detail-title", children: [_jsx("h3", { id: "brief-detail-title", className: "op-detail__title", children: record.requestId }), _jsx("div", { className: "op-badges", children: _jsx(Badge, { tone: state.tone, children: state.text }) }), _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Brief id" }), _jsx("dd", { className: "op-mono", children: record.briefId }), _jsx("dt", { children: "Revision" }), _jsx("dd", { children: groupDigits(String(record.revision)) }), _jsx("dt", { children: "Created (UTC)" }), _jsx("dd", { children: instantLabel(record.createdAtUtc, "UTC") }), _jsx("dt", { children: "Last changed (UTC)" }), _jsx("dd", { children: instantLabel(record.updatedAtUtc, "UTC") })] }), _jsx("h4", { className: "op-section-title", children: "Document summary" }), _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Brief type" }), _jsx("dd", { children: orNotStated(summary.briefType) }), _jsx("dt", { children: "Platforms" }), _jsx("dd", { children: listOrNone(summary.platforms) }), _jsx("dt", { children: "Deliverables" }), _jsx("dd", { children: listOrNone(summary.deliverables) }), _jsx("dt", { children: "Hook" }), _jsx("dd", { children: orNotStated(summary.hook) }), _jsx("dt", { children: "Review mode" }), _jsx("dd", { children: orNotStated(summary.reviewMode) }), _jsx("dt", { children: "Review policy revision" }), _jsx("dd", { children: summary.policyRevision === null ? "Not stated" : groupDigits(String(summary.policyRevision)) }), _jsx("dt", { children: "Stages" }), _jsx("dd", { children: groupDigits(String(summary.stageCount)) })] }), _jsx("h4", { className: "op-section-title", children: "Full document" }), _jsx("pre", { className: "op-pre", tabIndex: 0, "aria-label": "Full brief document as stored", children: JSON.stringify(record.document, null, 2) }), _jsx("h4", { className: "op-section-title", children: "Delivery" }), requestEnvelopes.length > 0 ? (_jsxs(_Fragment, { children: [record.state === "partial" ? (_jsx("p", { className: "op-hint", children: "Partial: the editor engine produced only some of what was requested. The envelopes below are the ones it delivered; what it skipped or refused is listed under the dispatch attempts." })) : null, requestEnvelopes.map((envelope) => (_jsx(EnvelopeDetail, { source: source, rev: rev, scopeId: scopeId, envelopeId: envelope.envelopeId }, envelope.envelopeId)))] })) : record.state === "delivered" ? (_jsx("p", { className: "op-hint", children: "This brief is marked delivered, but no envelope for it is stored in this channel." })) : (_jsx("p", { className: "op-hint", children: "No delivery has been accepted for this brief yet." })), _jsx("h4", { className: "op-section-title", children: "Dispatch attempts" }), attempts.length === 0 ? (_jsx("p", { className: "op-hint", children: "No dispatch attempt has been made for this brief." })) : (_jsx(AttemptsTable, { attempts: attempts }))] }));
57
+ }
58
+ /** The delivery envelope the engine accepted for a brief, read with delivery.get. */
59
+ function EnvelopeDetail({ source, rev, scopeId, envelopeId, }) {
60
+ const { state, reload } = useAsync(() => source.getDelivery(scopeId, envelopeId), [source, rev, scopeId, envelopeId]);
61
+ if (state.status === "error") {
62
+ return _jsx(LoadError, { message: "Could not read this delivery from the daemon.", onRetry: reload });
63
+ }
64
+ if (state.status === "loading")
65
+ return _jsx(SkeletonRows, { label: "Loading the delivery", rows: 2 });
66
+ const delivery = state.value;
67
+ if (delivery === null)
68
+ return _jsx("p", { className: "op-hint", children: "This delivery is no longer in this channel." });
69
+ return (_jsxs(_Fragment, { children: [_jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Deliverable" }), _jsx("dd", { className: "op-mono", children: delivery.deliverable === "" ? "Not named" : delivery.deliverable }), _jsx("dt", { children: "Envelope id" }), _jsx("dd", { className: "op-mono", children: delivery.envelopeId }), _jsx("dt", { children: "Brief id" }), _jsx("dd", { className: "op-mono", children: delivery.briefId }), _jsx("dt", { children: "Brief revision" }), _jsx("dd", { children: groupDigits(String(delivery.briefRevision)) }), _jsx("dt", { children: "Accepted (UTC)" }), _jsx("dd", { children: instantLabel(delivery.acceptedAtUtc, "UTC") })] }), _jsx("p", { className: "op-hint", children: "The envelope as the engine stored it:" }), _jsx("pre", { className: "op-pre", tabIndex: 0, "aria-label": `Delivery envelope for ${delivery.deliverable === "" ? "an unnamed deliverable" : delivery.deliverable} as stored`, children: JSON.stringify(delivery.document, null, 2) })] }));
70
+ }
71
+ /** Every dispatch attempt of one brief. Stderr is shown in a block of its own, so it is never read as prose. */
72
+ function AttemptsTable({ attempts }) {
73
+ return (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { children: [_jsx(TableCaption, { children: "Dispatch attempts of this brief, oldest first" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { align: "end", children: "Attempt" }), _jsx(TableHeaderCell, { children: "Engine" }), _jsx(TableHeaderCell, { children: "Outcome" }), _jsx(TableHeaderCell, { children: "Started (UTC)" }), _jsx(TableHeaderCell, { children: "Finished (UTC)" }), _jsx(TableHeaderCell, { align: "end", children: "Exit code" }), _jsx(TableHeaderCell, { children: "Message" }), _jsx(TableHeaderCell, { children: "Stderr tail" })] }) }), _jsx(TableBody, { children: attempts.map((run) => {
74
+ const label = DISPATCH_OUTCOME_LABEL[run.outcome];
75
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, numeric: true, children: groupDigits(String(run.attemptId)) }), _jsx(TableCell, { children: run.engine }), _jsx(TableCell, { children: _jsx(Badge, { tone: label.tone, children: label.text }) }), _jsx(TableCell, { className: "op-mono", children: instantLabel(run.startedAtUtc, "UTC") }), _jsx(TableCell, { className: "op-mono", children: run.finishedAtUtc === null ? "Not finished" : instantLabel(run.finishedAtUtc, "UTC") }), _jsx(TableCell, { numeric: true, children: run.exitCode === null ? "None" : String(run.exitCode) }), _jsxs(TableCell, { children: [run.message ?? "None", run.producedDeliverables.length > 0 ? (_jsxs("p", { className: "op-hint", children: ["Envelopes for: ", run.producedDeliverables.join(", ")] })) : null, run.skipped.length > 0 ? (_jsx("ul", { "aria-label": `Skipped by attempt ${run.attemptId}`, children: run.skipped.map((entry, index) => (_jsxs("li", { children: ["Skipped ", entry.deliverable ?? "a deliverable", ": ", entry.code ?? "no code", entry.message === null ? "" : ` (${entry.message})`] }, `${entry.deliverable ?? "unnamed"}-${index}`))) })) : null] }), _jsx(TableCell, { children: run.stderrTail === null || run.stderrTail === "" ? ("None kept") : (_jsx("pre", { className: "op-pre", tabIndex: 0, "aria-label": `Stderr tail of attempt ${run.attemptId}`, children: run.stderrTail })) })] }, run.attemptId));
76
+ }) })] }) }));
77
+ }
78
+ function listOrNone(items) {
79
+ return items.length === 0 ? "None" : items.join(", ");
80
+ }
81
+ function orNotStated(text) {
82
+ return text === null || text === "" ? "Not stated" : text;
83
+ }
@@ -0,0 +1,10 @@
1
+ import { type DataSource } from "../data/index.js";
2
+ interface Props {
3
+ source: DataSource;
4
+ /** Bumped by the app after any write, so the calendar re-reads. */
5
+ rev: number;
6
+ onOpenJob: (jobId: string) => void;
7
+ onOpenApprovals: () => void;
8
+ }
9
+ export declare function CalendarScreen({ source, rev, onOpenJob, onOpenApprovals }: Props): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,143 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { moveIndex } from "@rightkit/platform-ui/headless";
4
+ import { Button, Card, EmptyState, PageShell, SegmentedControl, SkeletonRows } from "@rightkit/react-shell/react";
5
+ import { AccountChip, JobBadges, LoadError, MockBanner, PublishProgressView } from "../components/Shared.js";
6
+ import { useAsync } from "../hooks/useAsync.js";
7
+ import { accountOrdinals, dateKeyAdd, dateKeyInZone, dayLabel, instantLabel, mediaReady, mondayOf, startOfDateUtc, timeInZone, weekDays, zoneAbbr, } from "../data/index.js";
8
+ import { FORMAT_LABEL, MEDIA_STATUS_LABEL } from "./labels.js";
9
+ const operatorZone = (() => {
10
+ try {
11
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
12
+ }
13
+ catch {
14
+ return "UTC";
15
+ }
16
+ })();
17
+ function mediaSummary(job) {
18
+ if (mediaReady(job)) {
19
+ const n = job.mediaItems.length;
20
+ return `Media ready${n > 1 ? ` (${n} items)` : ""}`;
21
+ }
22
+ const uploading = job.mediaItems.find((m) => m.status === "uploading");
23
+ if (uploading)
24
+ return `Media uploading ${uploading.uploadPercent ?? 0}%`;
25
+ if (job.mediaItems.some((m) => m.status === "missing"))
26
+ return `Media ${MEDIA_STATUS_LABEL.missing.toLowerCase()}`;
27
+ return "Media not ready";
28
+ }
29
+ export function CalendarScreen({ source, rev, onOpenJob, onOpenApprovals }) {
30
+ const today = dateKeyInZone(source.nowUtc(), operatorZone);
31
+ const [anchor, setAnchor] = useState(today);
32
+ const [view, setView] = useState("week");
33
+ const [selectedId, setSelectedId] = useState(null);
34
+ const days = useMemo(() => (view === "week" ? weekDays(mondayOf(anchor)) : [anchor]), [anchor, view]);
35
+ const first = days[0];
36
+ const last = days[days.length - 1];
37
+ // One day of slack each side, because a job is bucketed by its account's local date.
38
+ const fromUtc = startOfDateUtc(dateKeyAdd(first, -1));
39
+ const toUtc = startOfDateUtc(dateKeyAdd(last, 2));
40
+ const { state, reload } = useAsync(async () => {
41
+ const [accounts, jobs] = await Promise.all([
42
+ source.listAccounts(),
43
+ source.listJobs({ fromUtc, toUtc }),
44
+ ]);
45
+ return { accounts, jobs };
46
+ }, [source, fromUtc, toUtc, rev]);
47
+ // Keep the last good data on screen while a new range loads, so arrow-key moves do not flash.
48
+ const lastGood = useRef(null);
49
+ useEffect(() => {
50
+ if (state.status === "ready")
51
+ lastGood.current = state.value;
52
+ }, [state]);
53
+ const data = state.status === "ready" ? state.value : lastGood.current;
54
+ const containerRef = useRef(null);
55
+ const pendingFocus = useRef(false);
56
+ useEffect(() => {
57
+ if (!pendingFocus.current || !containerRef.current)
58
+ return;
59
+ const firstEntry = containerRef.current.querySelector("[data-entry]");
60
+ const target = firstEntry ?? containerRef.current.querySelector("[data-day-heading]");
61
+ if (target) {
62
+ target.focus();
63
+ pendingFocus.current = false;
64
+ }
65
+ });
66
+ const ordinals = useMemo(() => accountOrdinals(data?.accounts ?? []), [data]);
67
+ const accountById = useMemo(() => new Map((data?.accounts ?? []).map((a) => [a.id, a])), [data]);
68
+ const jobsByDay = useMemo(() => {
69
+ const map = new Map();
70
+ for (const day of days)
71
+ map.set(day, []);
72
+ for (const job of data?.jobs ?? []) {
73
+ const key = dateKeyInZone(job.scheduledAtUtc, job.timeZone);
74
+ const list = map.get(key);
75
+ if (list)
76
+ list.push(job);
77
+ }
78
+ for (const list of map.values())
79
+ list.sort((a, b) => a.scheduledAtUtc.localeCompare(b.scheduledAtUtc));
80
+ return map;
81
+ }, [data, days]);
82
+ const visibleCount = [...jobsByDay.values()].reduce((n, list) => n + list.length, 0);
83
+ const selected = selectedId ? data?.jobs.find((j) => j.id === selectedId) ?? null : null;
84
+ function onKeyDown(event) {
85
+ const target = event.target;
86
+ if (!target.matches("[data-entry]") || !containerRef.current)
87
+ return;
88
+ const entries = Array.from(containerRef.current.querySelectorAll("[data-entry]"));
89
+ const day = target.dataset.day ?? "";
90
+ switch (event.key) {
91
+ case "ArrowDown":
92
+ case "ArrowUp": {
93
+ event.preventDefault();
94
+ const sameDay = entries.filter((el) => el.dataset.day === day);
95
+ sameDay[moveIndex(sameDay.indexOf(target), sameDay.length, event.key === "ArrowDown" ? "next" : "prev", false)]?.focus();
96
+ return;
97
+ }
98
+ case "ArrowLeft":
99
+ case "ArrowRight": {
100
+ event.preventDefault();
101
+ const dir = event.key === "ArrowRight" ? 1 : -1;
102
+ if (view === "day") {
103
+ pendingFocus.current = true;
104
+ setAnchor((a) => dateKeyAdd(a, dir));
105
+ setSelectedId(null);
106
+ return;
107
+ }
108
+ // The next day that has an entry, in the arrow's direction: owner moveIndex skips the empty days.
109
+ const index = days.indexOf(day);
110
+ const hasEntry = (i) => entries.some((el) => el.dataset.day === days[i]);
111
+ const k = moveIndex(index, days.length, dir > 0 ? "next" : "prev", false, (i) => !hasEntry(i));
112
+ if (k !== index)
113
+ entries.find((el) => el.dataset.day === days[k])?.focus();
114
+ return;
115
+ }
116
+ case "Home":
117
+ case "End": {
118
+ event.preventDefault();
119
+ const sameDay = entries.filter((el) => el.dataset.day === day);
120
+ sameDay[moveIndex(0, sameDay.length, event.key === "Home" ? "first" : "last", false)]?.focus();
121
+ return;
122
+ }
123
+ default:
124
+ return;
125
+ }
126
+ }
127
+ const rangeLabel = view === "week" ? `Week of ${dayLabel(first)} to ${dayLabel(last)}` : dayLabel(anchor);
128
+ // A plain render function, not a nested component: a nested component would remount on every
129
+ // render and drop keyboard focus.
130
+ function renderEntry(job, day) {
131
+ const account = accountById.get(job.accountId);
132
+ return (_jsxs(Button, { type: "button", "data-entry": true, "data-day": day, variant: "secondary", className: "op-entry", "aria-current": job.id === selectedId ? "true" : undefined, onClick: () => setSelectedId(job.id), children: [account ? (_jsx(AccountChip, { account: account, ordinal: ordinals.get(account.id) })) : (_jsxs("span", { children: ["Account not found in the accounts list: ", _jsx("span", { className: "op-mono", children: job.accountId })] })), _jsxs("span", { className: "op-entry__line", children: [_jsx("time", { dateTime: job.scheduledAtUtc, children: timeInZone(job.scheduledAtUtc, job.timeZone) }), " ", zoneAbbr(job.scheduledAtUtc, job.timeZone), " \u00B7 ", FORMAT_LABEL[job.format]] }), _jsx(JobBadges, { job: job, compact: true }), _jsx("span", { className: "op-entry__media", children: mediaSummary(job) })] }));
133
+ }
134
+ return (_jsxs(PageShell, { title: "Calendar", subtitle: "Scheduled and media-ready posts for every account. Times are each account's local time.", actions: _jsxs("div", { className: "op-toolbar", children: [_jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAnchor((a) => dateKeyAdd(a, view === "week" ? -7 : -1)), children: view === "week" ? "Previous week" : "Previous day" }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAnchor(today), children: "Today" }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setAnchor((a) => dateKeyAdd(a, view === "week" ? 7 : 1)), children: view === "week" ? "Next week" : "Next day" }), _jsx(SegmentedControl, { label: "View", value: view, onChange: (v) => setView(v), options: [
135
+ { value: "day", label: "Day" },
136
+ { value: "week", label: "Week" },
137
+ ] })] }), children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, _jsx("p", { className: "op-range", "aria-live": "polite", children: rangeLabel }), _jsx("p", { className: "op-hint", children: "Arrow keys move between entries. Up and Down stay inside one day; Left and Right move between days." }), state.status === "error" ? (_jsx(LoadError, { message: "Could not read the calendar from the daemon.", onRetry: reload })) : data === null ? (_jsx(SkeletonRows, { label: "Loading the calendar", rows: 4 })) : (_jsxs(_Fragment, { children: [state.status === "loading" ? _jsx("p", { role: "status", className: "op-hint", children: "Updating" }) : null, visibleCount === 0 ? (_jsxs(EmptyState, { title: "Nothing scheduled for these days", children: [_jsx("p", { children: "Accounts publish only approved, scheduled posts. Drafts waiting for a decision are in Approvals." }), _jsx(Button, { variant: "secondary", onClick: onOpenApprovals, children: "Open approvals" })] })) : null, _jsx("div", { className: "op-week-scroll", ref: containerRef, onKeyDown: onKeyDown, children: _jsx("div", { className: view === "week" ? "op-week" : "op-day-list", children: days.map((day) => {
138
+ const entries = jobsByDay.get(day) ?? [];
139
+ const isToday = day === today;
140
+ return (_jsxs("section", { className: "op-day", "aria-labelledby": `day-${day}`, children: [_jsxs("h3", { id: `day-${day}`, "data-day-heading": true, tabIndex: -1, className: "op-day__heading", children: [dayLabel(day), isToday ? _jsx("span", { className: "op-today", children: " (today)" }) : null] }), entries.length === 0 ? (_jsx("p", { className: "op-day__empty", children: "Nothing scheduled" })) : (_jsx("ul", { className: "op-day__list", children: entries.map((job) => (_jsx("li", { children: renderEntry(job, day) }, job.id))) }))] }, day));
141
+ }) }) }), selected ? (_jsxs(Card, { as: "section", className: "op-detail", children: [_jsxs("h2", { className: "op-detail__title", children: [accountById.get(selected.accountId)?.displayName ??
142
+ `Account not found in the accounts list (${selected.accountId})`, ": ", instantLabel(selected.scheduledAtUtc, selected.timeZone)] }), _jsx(JobBadges, { job: selected }), selected.publish ? _jsx(PublishProgressView, { progress: selected.publish, timeZone: selected.timeZone }) : null, selected.dst.kind !== "none" ? _jsx("p", { children: selected.dst.note }) : null, selected.state === "outcome_unknown" ? (_jsx("p", { role: "alert", children: "The platform may have accepted this post. No retry until reconciled." })) : null, selected.lateness ? (_jsxs("p", { children: ["Late: media was not ready by ", instantLabel(selected.lateness.mediaNotReadyBy, selected.timeZone), ". Daemon policy:", " ", selected.lateness.policy === "publish_next_slot" ? "publish at the next slot" : "held for review", "."] })) : null, selected.conflict ? (_jsxs("p", { children: ["This account has another job within ", selected.conflict.minimumSpacingMinutes, " minutes. Review both before approving."] })) : null, _jsx("div", { children: _jsx(Button, { variant: "primary", onClick: () => onOpenJob(selected.id), children: "Open in approvals" }) })] })) : (_jsx("p", { className: "op-hint", children: "Select an entry to see its details." }))] }))] }));
143
+ }
@@ -0,0 +1,7 @@
1
+ import { type DataSource } from "../data/index.js";
2
+ interface Props {
3
+ source: DataSource;
4
+ rev: number;
5
+ }
6
+ export declare function CampaignsScreen({ source, rev }: Props): import("react").JSX.Element;
7
+ export {};
@@ -0,0 +1,52 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { EmptyState, Field, PageShell, SkeletonRows, Table, TableBody, TableCaption, TableCell, TableHead, TableHeaderCell, TableRow } from "@rightkit/react-shell/react";
4
+ import { ScopeSelect } from "../components/ScopeSelect.js";
5
+ import { LoadError, MockBanner } from "../components/Shared.js";
6
+ import { useAsync } from "../hooks/useAsync.js";
7
+ import { groupDigits, instantLabel, } from "../data/index.js";
8
+ import { platformName } from "./labels.js";
9
+ /** A visit count as exact digits, grouped for reading. A missing count reads as not reported. */
10
+ function visitText(count) {
11
+ return count === undefined ? "Not reported" : groupDigits(String(count));
12
+ }
13
+ export function CampaignsScreen({ source, rev }) {
14
+ const scopes = useAsync(() => source.listScopes(), [source, rev]);
15
+ const [picked, setPicked] = useState(null);
16
+ const scopeList = scopes.value ?? [];
17
+ const scopeId = scopeList.find((s) => s.id === picked)?.id ?? scopeList[0]?.id ?? null;
18
+ return (_jsxs(PageShell, { title: "Campaigns", subtitle: "Read only. The campaigns of one channel, their tracked links and the visits recorded against those links. Nothing here creates a link, records a visit or redirects a visitor.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, scopes.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the channels from the daemon.", onRetry: scopes.reload })) : scopes.value === null ? (_jsx(SkeletonRows, { label: "Loading channels", rows: 2 })) : scopeList.length === 0 || scopeId === null ? (_jsx(EmptyState, { title: "No channels yet", children: _jsx("p", { children: "Campaigns are kept per channel once one is set up." }) })) : (_jsxs(_Fragment, { children: [_jsx(ScopeSelect, { scopes: scopeList, value: scopeId, onChange: (id) => setPicked(id) }), _jsx(CampaignView, { source: source, rev: rev, scopeId: scopeId }, scopeId)] }))] }));
19
+ }
20
+ function CampaignView({ source, rev, scopeId }) {
21
+ const { state, reload, value } = useAsync(() => source.listCampaigns(scopeId), [source, rev, scopeId]);
22
+ const [picked, setPicked] = useState(null);
23
+ if (state.status === "error") {
24
+ return _jsx(LoadError, { message: "Could not read the campaigns from the daemon.", onRetry: reload });
25
+ }
26
+ if (value === null)
27
+ return _jsx(SkeletonRows, { label: "Loading campaigns", rows: 3 });
28
+ if (value.length === 0) {
29
+ return (_jsx(EmptyState, { title: "No campaigns yet", children: _jsx("p", { children: "This channel has no campaign. A campaign is made on the daemon host, not here. This is not an error." }) }));
30
+ }
31
+ const selected = value.find((c) => c.id === picked) ?? value[0];
32
+ return (_jsxs(_Fragment, { children: [_jsxs("section", { "aria-labelledby": "campaigns-list-title", children: [_jsxs("h2", { id: "campaigns-list-title", className: "op-section-title", children: ["Campaigns (", value.length, ")"] }), _jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "campaigns-list-title", children: [_jsx(TableCaption, { children: "Campaigns in this channel" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Campaign" }), _jsx(TableHeaderCell, { children: "Campaign id" }), _jsx(TableHeaderCell, { children: "utm_campaign" }), _jsx(TableHeaderCell, { align: "end", children: "Revision" }), _jsx(TableHeaderCell, { children: "Last changed (UTC)" })] }) }), _jsx(TableBody, { children: value.map((campaign) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, children: campaign.name }), _jsx(TableCell, { className: "op-mono", children: campaign.id }), _jsx(TableCell, { className: "op-mono", children: campaign.utmCampaign }), _jsx(TableCell, { numeric: true, children: campaign.revision }), _jsx(TableCell, { className: "op-mono", children: instantLabel(campaign.updatedAtUtc, "UTC") })] }, campaign.id))) })] }) })] }), _jsxs("section", { "aria-labelledby": "campaign-links-title", children: [_jsx("h2", { id: "campaign-links-title", className: "op-section-title", children: "Tracked links and visits" }), _jsx(Field, { label: "Campaign", children: ({ id, "aria-describedby": describedBy }) => (_jsx("select", { id: id, "aria-describedby": describedBy, className: "op-select", value: selected.id, onChange: (e) => setPicked(e.target.value), children: value.map((campaign) => (_jsx("option", { value: campaign.id, children: campaign.name }, campaign.id))) })) }), _jsx(CampaignLinks, { source: source, rev: rev, scopeId: scopeId, campaign: selected }, selected.id)] })] }));
33
+ }
34
+ function CampaignLinks({ source, rev, scopeId, campaign, }) {
35
+ const links = useAsync(() => source.listCampaignLinks(scopeId, campaign.id), [source, rev, scopeId, campaign.id]);
36
+ const attribution = useAsync(() => source.campaignAttribution(scopeId, campaign.id), [source, rev, scopeId, campaign.id]);
37
+ const linkState = links.state;
38
+ const countState = attribution.state;
39
+ if (linkState.status === "error") {
40
+ return _jsx(LoadError, { message: "Could not read the tracked links from the daemon.", onRetry: links.reload });
41
+ }
42
+ if (countState.status === "error") {
43
+ return _jsx(LoadError, { message: "Could not read the visit counts from the daemon.", onRetry: attribution.reload });
44
+ }
45
+ if (linkState.status === "loading" || countState.status === "loading") {
46
+ return _jsx(SkeletonRows, { label: "Loading the links and the visit counts", rows: 3 });
47
+ }
48
+ const linkList = linkState.value;
49
+ const counts = countState.value;
50
+ const visitsByLink = new Map((counts?.links ?? []).map((row) => [row.linkId, row.visits]));
51
+ return (_jsxs(_Fragment, { children: [counts === null ? (_jsx("p", { className: "op-hint", children: "The engine holds no visit counts for this campaign in this channel." })) : (_jsxs("p", { className: "op-hint", children: ["Total visits across this campaign's links: ", groupDigits(String(counts.totalVisits)), ". These are the counts the daemon holds. A link with zero visits is a normal state: no visit has been recorded for it yet. The desktop records no visits."] })), linkList.length === 0 ? (_jsx("p", { className: "op-hint", children: "This campaign has no tracked links yet. Links are cut on the daemon host, not here. This is not an error." })) : (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "campaign-links-title", children: [_jsxs(TableCaption, { children: ["Tracked links of ", campaign.name] }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Link" }), _jsx(TableHeaderCell, { children: "Variant" }), _jsx(TableHeaderCell, { children: "Platform" }), _jsx(TableHeaderCell, { children: "Final URL" }), _jsx(TableHeaderCell, { children: "utm_source" }), _jsx(TableHeaderCell, { children: "utm_medium" }), _jsx(TableHeaderCell, { children: "utm_campaign" }), _jsx(TableHeaderCell, { children: "utm_content" }), _jsx(TableHeaderCell, { children: "Calendar entry" }), _jsx(TableHeaderCell, { align: "end", children: "Visits" })] }) }), _jsx(TableBody, { children: linkList.map((link) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: link.id }), _jsx(TableCell, { children: link.variantId }), _jsx(TableCell, { children: platformName(link.platform) }), _jsx(TableCell, { className: "op-mono", children: link.finalUrl }), _jsx(TableCell, { className: "op-mono", children: link.utmSource }), _jsx(TableCell, { className: "op-mono", children: link.utmMedium }), _jsx(TableCell, { className: "op-mono", children: link.utmCampaign }), _jsx(TableCell, { className: "op-mono", children: link.utmContent }), _jsx(TableCell, { className: "op-mono", children: link.calendarEntryId ?? "None" }), _jsx(TableCell, { numeric: true, children: visitText(visitsByLink.get(link.id)) })] }, link.id))) })] }) }))] }));
52
+ }
@@ -0,0 +1,7 @@
1
+ import { type DataSource } from "../data/index.js";
2
+ interface Props {
3
+ source: DataSource;
4
+ rev: number;
5
+ }
6
+ export declare function ChannelMemoryScreen({ source, rev }: Props): import("react").JSX.Element;
7
+ export {};