@growthub/cli 0.13.6 → 0.13.8

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 (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +98 -34
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +1 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +106 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +189 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceContributionGraph.jsx +119 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +357 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +488 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensWalkthrough.jsx +69 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +37 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/NangoConnectionPanel.jsx +37 -2
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +437 -26
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +44 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +592 -41
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-lens/page.jsx +76 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +148 -4
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +1559 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +3 -3
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +24 -8
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +82 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +8 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/project-management.config.json +4 -4
  22. package/dist/index.js +5224 -5225
  23. package/package.json +1 -1
@@ -11,8 +11,8 @@
11
11
  * Execution model:
12
12
  * 1. Read and sanitize the live growthub.config.json (or accept a snapshot).
13
13
  * 2. Build a workspace-grammar-injected system prompt via workspace-helper.js.
14
- * 3. Dispatch through the local-intelligence sandbox adapter.
15
- * 4. Parse the growthub-local-model-sandbox-v1 envelope.
14
+ * 3. Dispatch through the configured workspace-helper-sandbox adapter.
15
+ * 4. Parse the helper JSON envelope.
16
16
  * 5. Validate proposals, write a run record to source-records, return response.
17
17
  *
18
18
  * Request body (WorkspaceHelperQuery):
@@ -73,11 +73,38 @@ const VALID_INTENTS = [
73
73
  ];
74
74
 
75
75
  const HELPER_SOURCE_KEY_PREFIX = "helper";
76
+ const HELPER_SANDBOX_OBJECT_ID = "workspace-helper-sandbox";
76
77
 
77
78
  function helperSourceId(intent, runId) {
78
79
  return `${HELPER_SOURCE_KEY_PREFIX}:${intent}:${runId}`;
79
80
  }
80
81
 
82
+ function findHelperSandboxRow(config) {
83
+ const objects = Array.isArray(config?.dataModel?.objects) ? config.dataModel.objects : [];
84
+ const helperObject = objects.find((o) => o?.id === HELPER_SANDBOX_OBJECT_ID && o?.objectType === "sandbox-environment");
85
+ const rows = Array.isArray(helperObject?.rows) ? helperObject.rows : [];
86
+ return rows[0] && typeof rows[0] === "object" ? rows[0] : null;
87
+ }
88
+
89
+ function buildAgentHostCommand(messages) {
90
+ const transcript = messages
91
+ .map((message) => {
92
+ const role = typeof message?.role === "string" ? message.role.toUpperCase() : "MESSAGE";
93
+ const content = typeof message?.content === "string" ? message.content : "";
94
+ return `### ${role}\n${content}`;
95
+ })
96
+ .join("\n\n");
97
+
98
+ return [
99
+ "You are the Growthub Workspace Helper running through the user's selected local agent host.",
100
+ "Use the full transcript below. Return only one JSON object with this exact shape:",
101
+ JSON.stringify({ summary: "", proposals: [], warnings: [] }),
102
+ "Do not wrap the JSON in markdown. Do not execute tools or mutate files.",
103
+ "",
104
+ transcript,
105
+ ].join("\n");
106
+ }
107
+
81
108
  async function POST(request) {
82
109
  let body;
83
110
  try {
@@ -117,6 +144,13 @@ async function POST(request) {
117
144
  snapshot = sanitizeWorkspaceSnapshot(liveConfig);
118
145
  liveConfigForThread = liveConfig;
119
146
  }
147
+ if (!liveConfigForThread) {
148
+ try {
149
+ liveConfigForThread = await readWorkspaceConfig();
150
+ } catch {
151
+ liveConfigForThread = null;
152
+ }
153
+ }
120
154
 
121
155
  // Thread continuity — pull prior messages from the governed helper-threads
122
156
  // row when the caller supplied a threadId so the chat completion gets the
@@ -170,12 +204,17 @@ async function POST(request) {
170
204
  const userIntent = userPrompt;
171
205
 
172
206
  await ensureSandboxAdaptersLoaded();
173
- const adapter = getSandboxAdapter("local-intelligence");
207
+ const helperSandboxRow = findHelperSandboxRow(liveConfigForThread);
208
+ const helperAdapterId = String(helperSandboxRow?.adapter || "").trim();
209
+ const helperAgentHost = String(helperSandboxRow?.agentHost || "").trim();
210
+ const useAgentHost = helperAdapterId === "local-agent-host" && helperAgentHost;
211
+ const adapterId = useAgentHost ? "local-agent-host" : "local-intelligence";
212
+ const adapter = getSandboxAdapter(adapterId);
174
213
  if (!adapter) {
175
214
  return NextResponse.json(
176
215
  {
177
216
  ok: false,
178
- error: "local-intelligence adapter not registered. Ensure sandbox adapters are loaded.",
217
+ error: `${adapterId} adapter not registered. Ensure sandbox adapters are loaded.`,
179
218
  },
180
219
  { status: 503 }
181
220
  );
@@ -186,32 +225,50 @@ async function POST(request) {
186
225
 
187
226
  let adapterResult;
188
227
  try {
189
- adapterResult = await adapter.run({
190
- runId,
191
- name: `workspace-helper-${resolvedIntent}`,
192
- runtime: "node",
193
- agentHost: "",
194
- command: userIntent,
195
- timeoutMs: 90000,
196
- networkAllow: true,
197
- allowList: [],
198
- env: {},
199
- envRefSlugs: [],
200
- envRefsMissing: [],
201
- workdir: "/tmp",
202
- ranAt,
203
- intelligenceSandbox: {
204
- // Structured multi-turn message array — the adapter passes this
205
- // straight through to the chat completions endpoint so the model
206
- // sees the full conversation history with stable system prefix
207
- // (KV-cache friendly).
208
- messages: chatMessages,
209
- userIntent,
210
- localModel: modelOverride || process.env.NATIVE_INTELLIGENCE_LOCAL_MODEL || process.env.OLLAMA_MODEL || "gemma3:4b",
211
- localEndpoint: localEndpointOverride || "",
212
- intelligenceAdapterMode: adapterModeOverride || "ollama",
213
- },
214
- });
228
+ if (useAgentHost) {
229
+ adapterResult = await adapter.run({
230
+ runId,
231
+ name: `workspace-helper-${resolvedIntent}`,
232
+ runtime: "node",
233
+ agentHost: helperAgentHost,
234
+ command: buildAgentHostCommand(chatMessages),
235
+ timeoutMs: Number(helperSandboxRow?.timeoutMs) || 120000,
236
+ networkAllow: true,
237
+ allowList: [],
238
+ env: {},
239
+ envRefSlugs: [],
240
+ envRefsMissing: [],
241
+ workdir: "/tmp",
242
+ ranAt,
243
+ });
244
+ } else {
245
+ adapterResult = await adapter.run({
246
+ runId,
247
+ name: `workspace-helper-${resolvedIntent}`,
248
+ runtime: "node",
249
+ agentHost: "",
250
+ command: userIntent,
251
+ timeoutMs: 90000,
252
+ networkAllow: true,
253
+ allowList: [],
254
+ env: {},
255
+ envRefSlugs: [],
256
+ envRefsMissing: [],
257
+ workdir: "/tmp",
258
+ ranAt,
259
+ intelligenceSandbox: {
260
+ // Structured multi-turn message array — the adapter passes this
261
+ // straight through to the chat completions endpoint so the model
262
+ // sees the full conversation history with stable system prefix
263
+ // (KV-cache friendly).
264
+ messages: chatMessages,
265
+ userIntent,
266
+ localModel: modelOverride || process.env.NATIVE_INTELLIGENCE_LOCAL_MODEL || process.env.OLLAMA_MODEL || "gemma3:4b",
267
+ localEndpoint: localEndpointOverride || "",
268
+ intelligenceAdapterMode: adapterModeOverride || "ollama",
269
+ },
270
+ });
271
+ }
215
272
  } catch (err) {
216
273
  return NextResponse.json(
217
274
  {
@@ -226,10 +283,10 @@ async function POST(request) {
226
283
  return NextResponse.json(
227
284
  {
228
285
  ok: false,
229
- error: adapterResult.error || "local-intelligence adapter returned error",
286
+ error: adapterResult.error || `${adapterId} adapter returned error`,
230
287
  receipts: {
231
- model: "unknown",
232
- adapterMode: adapterModeOverride || "ollama",
288
+ model: useAgentHost ? helperAgentHost : "unknown",
289
+ adapterMode: useAgentHost ? "agent-host" : (adapterModeOverride || "ollama"),
233
290
  endpoint: "",
234
291
  confidence: 0,
235
292
  latencyMs: adapterResult.durationMs || 0,
@@ -241,7 +298,14 @@ async function POST(request) {
241
298
  );
242
299
  }
243
300
 
244
- const parsed = parseHelperEnvelope(adapterResult.stdout, intent);
301
+ const parsedInput = useAgentHost
302
+ ? {
303
+ result: { text: adapterResult.stdout },
304
+ adapter: { mode: "agent-host", modelId: helperAgentHost, endpoint: "local-agent-host" },
305
+ latencyMs: adapterResult.durationMs || 0,
306
+ }
307
+ : adapterResult.stdout;
308
+ const parsed = parseHelperEnvelope(parsedInput, intent);
245
309
  const { valid: validProposals, errors: validationErrors } = validateProposals(parsed.proposals);
246
310
 
247
311
  const warnings = [...parsed.warnings];
@@ -152,6 +152,7 @@ async function GET(request) {
152
152
  runs: metadataStore.runs,
153
153
  outputArtifacts: metadataStore.outputArtifacts,
154
154
  workerKits: metadataStore.workerKits,
155
+ provenance: metadataStore.provenance,
155
156
  pipelineHealth: metadataStore.pipelineHealth
156
157
  },
157
158
  graph: {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * GET /api/workspace/swarm-condition
3
+ *
4
+ * Growthub Workspace Swarm Condition Packet V1 — read-only projection.
5
+ *
6
+ * Composes a registered workspace state lens into the agent-swarm assignment
7
+ * shape: instead of a vague prompt, an agent (or a swarm) gets a workspace
8
+ * *condition* — goal, current state, the blocked step, its prerequisite, the
9
+ * tools available, and the evidence it must produce. The human activation
10
+ * panel and this packet read the identical derived state.
11
+ *
12
+ * Optional query parameter:
13
+ * - lensId: "activation" (default) | "persistence" | "observability" |
14
+ * "deploy" | "tasks" | "app-build". Unknown ids fall back to
15
+ * "activation".
16
+ *
17
+ * Authority invariants:
18
+ * - GET only. PATCH / POST / PUT / DELETE are not exposed. Writes still flow
19
+ * through the existing governed routes (`PATCH /api/workspace`,
20
+ * `POST /api/workspace/sandbox-run`, etc.).
21
+ * - growthub.config.json remains the authoritative artifact.
22
+ * - No secrets, connection IDs, or tokens are returned. The runtime block is
23
+ * assembled from safe descriptors (persistence mode + adapter booleans).
24
+ * - Read OR derivation failures fall back to a typed packet with warnings —
25
+ * this route never throws.
26
+ */
27
+
28
+ import { NextResponse } from "next/server";
29
+ import { readWorkspaceConfig, readWorkspaceSourceRecords, describePersistenceMode } from "@/lib/workspace-config";
30
+ import { readAdapterConfig } from "@/lib/adapters/env";
31
+ import { deriveSwarmConditionPacket } from "@/lib/workspace-activation";
32
+
33
+ const SWARM_PACKET_KIND = "growthub-swarm-condition-packet-v1";
34
+
35
+ /** Assemble the safe runtime descriptor the lenses read (no secrets/values). */
36
+ function safeRuntime(warnings) {
37
+ const runtime = { persistenceMode: "", persistenceAdapter: null, allowFsWrite: false, nangoConfigured: false, deploy: {} };
38
+ try {
39
+ const persistence = describePersistenceMode();
40
+ runtime.persistenceMode = persistence.mode;
41
+ runtime.allowFsWrite = persistence.mode === "filesystem" && persistence.canSave === true;
42
+ const adapter = readAdapterConfig();
43
+ runtime.persistenceAdapter = persistence.mode === "database" ? (adapter.dataAdapter || null) : null;
44
+ runtime.nangoConfigured = Boolean(adapter?.nango?.hasSecretKey);
45
+ runtime.deploy = { target: adapter.deployTarget || "" };
46
+ } catch (error) {
47
+ warnings.push(`Failed to read runtime descriptor: ${error?.message || "unknown error"}`);
48
+ }
49
+ return runtime;
50
+ }
51
+
52
+ async function GET(request) {
53
+ const warnings = [];
54
+
55
+ let lensId = "activation";
56
+ try {
57
+ const url = request && request.url ? new URL(request.url) : null;
58
+ const requested = url ? (url.searchParams.get("lensId") || "").trim() : "";
59
+ if (requested) lensId = requested;
60
+ } catch (error) {
61
+ warnings.push(`Failed to parse query: ${error?.message || "unknown error"}`);
62
+ }
63
+
64
+ let workspaceConfig = {};
65
+ try {
66
+ workspaceConfig = (await readWorkspaceConfig()) || {};
67
+ } catch (error) {
68
+ warnings.push(`Failed to read workspace config: ${error?.message || "unknown error"}`);
69
+ }
70
+
71
+ let workspaceSourceRecords = {};
72
+ try {
73
+ workspaceSourceRecords = (await readWorkspaceSourceRecords()) || {};
74
+ } catch (error) {
75
+ warnings.push(`Failed to read source records sidecar: ${error?.message || "unknown error"}`);
76
+ }
77
+
78
+ const metadataGraph = { runtime: safeRuntime(warnings) };
79
+
80
+ let packet;
81
+ try {
82
+ packet = deriveSwarmConditionPacket(
83
+ { workspaceConfig, workspaceSourceRecords, metadataGraph },
84
+ { lensId },
85
+ );
86
+ } catch (error) {
87
+ warnings.push(`Failed to derive swarm packet: ${error?.message || "unknown error"}`);
88
+ packet = {
89
+ kind: SWARM_PACKET_KIND,
90
+ version: 1,
91
+ lensId: "activation",
92
+ goal: "Activate this workspace.",
93
+ currentState: "0/0",
94
+ complete: false,
95
+ nextAction: null,
96
+ blockedStep: null,
97
+ prerequisite: null,
98
+ availableTools: [],
99
+ expectedEvidence: [],
100
+ };
101
+ }
102
+
103
+ return NextResponse.json({ ...packet, warnings });
104
+ }
105
+
106
+ export { GET };
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkspaceActivationPanel — customer activation checklist.
5
+ *
6
+ * Renders the derived activation state from
7
+ * `lib/workspace-activation.js` as a goal-first checklist with deep links
8
+ * into the existing surfaces. The panel is read-only; every step routes
9
+ * through routes the workspace already owns.
10
+ *
11
+ * Props:
12
+ * workspaceConfig — parsed growthub.config.json (governed)
13
+ * workspaceSourceRecords — parsed growthub.source-records.json (sidecar)
14
+ * metadataGraph — optional metadata graph envelope
15
+ * onOpenHelper — optional helper-thread CTA handler
16
+ * compact — render the rail-friendly compact variant
17
+ *
18
+ * Invariants:
19
+ * - No fetch. No mutation. No secret reading.
20
+ * - Reads only safe data (booleans, status strings, deep-link routes).
21
+ * - Falls back to a sensible default state if config is missing.
22
+ */
23
+
24
+ import Link from "next/link";
25
+ import {
26
+ ArrowRight,
27
+ Check,
28
+ CircleDot,
29
+ HelpCircle,
30
+ Lock,
31
+ Sparkles,
32
+ } from "lucide-react";
33
+ import { deriveWorkspaceActivationState } from "@/lib/workspace-activation";
34
+
35
+ function StatusIcon({ status }) {
36
+ if (status === "complete") return <Check size={14} aria-hidden="true" />;
37
+ if (status === "blocked") return <Lock size={14} aria-hidden="true" />;
38
+ if (status === "optional") return <Sparkles size={14} aria-hidden="true" />;
39
+ return <CircleDot size={14} aria-hidden="true" />;
40
+ }
41
+
42
+ function StatusLabel({ status }) {
43
+ if (status === "complete") return "Done";
44
+ if (status === "blocked") return "Blocked";
45
+ if (status === "optional") return "Optional";
46
+ return "Next";
47
+ }
48
+
49
+ export function WorkspaceActivationPanel({
50
+ workspaceConfig,
51
+ workspaceSourceRecords,
52
+ metadataGraph,
53
+ onOpenHelper,
54
+ compact = false,
55
+ showLenses = false,
56
+ }) {
57
+ const state = deriveWorkspaceActivationState({
58
+ workspaceConfig,
59
+ workspaceSourceRecords,
60
+ metadataGraph,
61
+ });
62
+
63
+ // Onboarding stays pure. Once the primary loop completes we don't render
64
+ // operating-state cards inside the checklist — we hand the user off to the
65
+ // dedicated Workspace Lens surface (the "you levelled up" moment).
66
+ const showLensTeaser = showLenses && !compact && state.complete;
67
+
68
+ const stepsToRender = compact
69
+ ? state.steps.filter((step) => step.status !== "optional")
70
+ : state.steps;
71
+
72
+ return (
73
+ <section
74
+ className={
75
+ "workspace-activation-panel"
76
+ + (compact ? " is-compact" : "")
77
+ + (state.complete ? " is-complete" : "")
78
+ }
79
+ aria-label="Workspace activation"
80
+ data-template={state.template}
81
+ >
82
+ <header className="workspace-activation-head">
83
+ <div className="workspace-activation-head-text">
84
+ <p className="workspace-activation-eyebrow">{state.templateName}</p>
85
+ <h2 className="workspace-activation-headline">{state.headline}</h2>
86
+ {state.subheadline ? (
87
+ <p className="workspace-activation-subheadline">{state.subheadline}</p>
88
+ ) : null}
89
+ </div>
90
+ <div
91
+ className="workspace-activation-progress"
92
+ aria-label={`${state.completedCount} of ${state.totalCount} setup steps complete`}
93
+ >
94
+ <span className="workspace-activation-progress-value">
95
+ {state.completedCount}/{state.totalCount}
96
+ </span>
97
+ <span className="workspace-activation-progress-bar" aria-hidden="true">
98
+ <span
99
+ className="workspace-activation-progress-fill"
100
+ style={{
101
+ width: state.totalCount > 0
102
+ ? `${Math.min(100, Math.round((state.completedCount / state.totalCount) * 100))}%`
103
+ : "0%",
104
+ }}
105
+ />
106
+ </span>
107
+ </div>
108
+ </header>
109
+
110
+ <ol className="workspace-activation-steps" role="list">
111
+ {stepsToRender.map((step) => {
112
+ const isNext = state.nextStepId === step.id;
113
+ return (
114
+ <li
115
+ key={step.id}
116
+ className={
117
+ "workspace-activation-step"
118
+ + ` is-${step.status}`
119
+ + (isNext ? " is-next" : "")
120
+ }
121
+ >
122
+ <span className="workspace-activation-step-status" aria-hidden="true">
123
+ <StatusIcon status={step.status} />
124
+ </span>
125
+ <div className="workspace-activation-step-body">
126
+ <div className="workspace-activation-step-titlebar">
127
+ <h3 className="workspace-activation-step-title">{step.label}</h3>
128
+ <span className={`workspace-activation-step-badge is-${step.status}`}>
129
+ <StatusLabel status={step.status} />
130
+ </span>
131
+ </div>
132
+ <p className="workspace-activation-step-description">{step.description}</p>
133
+ {step.hint ? (
134
+ <p className="workspace-activation-step-hint">
135
+ <HelpCircle size={12} aria-hidden="true" />
136
+ <span>{step.hint}</span>
137
+ </p>
138
+ ) : null}
139
+ {step.href ? (
140
+ <Link
141
+ href={step.href}
142
+ className={
143
+ "workspace-activation-step-cta"
144
+ + (isNext ? " is-primary" : "")
145
+ }
146
+ >
147
+ <span>{step.cta || (step.status === "complete" ? "Review" : "Open")}</span>
148
+ <ArrowRight size={12} aria-hidden="true" />
149
+ </Link>
150
+ ) : null}
151
+ </div>
152
+ </li>
153
+ );
154
+ })}
155
+ </ol>
156
+
157
+ {showLensTeaser ? (
158
+ <div className="workspace-activation-lens-teaser">
159
+ <span className="workspace-activation-lens-teaser-text">
160
+ Workspace Lens is now available — your live operating surface for state, blockers, and agent-assignable work.
161
+ </span>
162
+ <Link href="/workspace-lens" className="workspace-activation-lens-teaser-link">
163
+ Open Workspace Lens
164
+ </Link>
165
+ </div>
166
+ ) : null}
167
+
168
+ {onOpenHelper ? (
169
+ <div className="workspace-activation-helper-cta">
170
+ <button
171
+ type="button"
172
+ className="workspace-activation-helper-btn"
173
+ onClick={() => onOpenHelper({
174
+ template: state.template,
175
+ nextStepId: state.nextStepId,
176
+ })}
177
+ >
178
+ <Sparkles size={13} aria-hidden="true" />
179
+ <span>
180
+ {state.complete
181
+ ? "Ask helper to customize this workspace"
182
+ : `Ask helper to finish ${state.templateName}`}
183
+ </span>
184
+ </button>
185
+ </div>
186
+ ) : null}
187
+ </section>
188
+ );
189
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkspaceContributionGraph — GitHub-style activity heatmap for the workspace.
5
+ *
6
+ * Renders the pure `deriveWorkspaceContributions` grid (53 weeks × 7 days) as a
7
+ * calm, developer-familiar contribution graph. The chrome is neutral; only the
8
+ * cells use the recognizable green intensity scale. On hover each day shows a
9
+ * minimal tooltip (count + date) with a link into the matching filtered lens
10
+ * view — so the graph becomes the entry point to a daily ritual: open the lens,
11
+ * read your activity, drill into the day, start work.
12
+ */
13
+
14
+ import { useState } from "react";
15
+
16
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
17
+
18
+ function formatDay(iso) {
19
+ const d = new Date(iso);
20
+ if (Number.isNaN(d.getTime())) return iso;
21
+ return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
22
+ }
23
+
24
+ export function WorkspaceContributionGraph({ data, onSelectDay, buildDayHref }) {
25
+ const [hover, setHover] = useState(null);
26
+ if (!data || !Array.isArray(data.weeks) || data.weeks.length === 0) return null;
27
+
28
+ // Month labels: show a label above the first week whose first day starts a
29
+ // new month versus the previous column.
30
+ let prevMonth = -1;
31
+ const monthLabels = data.weeks.map((week) => {
32
+ const first = week.days[0];
33
+ const month = first ? new Date(first.date).getUTCMonth() : -1;
34
+ if (month !== prevMonth) { prevMonth = month; return MONTHS[month] || ""; }
35
+ return "";
36
+ });
37
+
38
+ return (
39
+ <section className="workspace-contrib" aria-label="Workspace contribution graph">
40
+ <div className="workspace-contrib-head">
41
+ <span className="workspace-contrib-total">{data.total} workspace contributions in the last year</span>
42
+ <span className="workspace-contrib-legend" aria-hidden="true">
43
+ Less
44
+ <i className="workspace-contrib-cell lvl-0" />
45
+ <i className="workspace-contrib-cell lvl-1" />
46
+ <i className="workspace-contrib-cell lvl-2" />
47
+ <i className="workspace-contrib-cell lvl-3" />
48
+ <i className="workspace-contrib-cell lvl-4" />
49
+ More
50
+ </span>
51
+ </div>
52
+
53
+ <div className="workspace-contrib-grid-wrap">
54
+ <div className="workspace-contrib-months" aria-hidden="true">
55
+ {monthLabels.map((label, i) => (
56
+ <span key={i} className="workspace-contrib-month">{label}</span>
57
+ ))}
58
+ </div>
59
+ <div className="workspace-contrib-body">
60
+ <div className="workspace-contrib-weekdays" aria-hidden="true">
61
+ <span />
62
+ <span>Mon</span>
63
+ <span />
64
+ <span>Wed</span>
65
+ <span />
66
+ <span>Fri</span>
67
+ <span />
68
+ </div>
69
+ <div className="workspace-contrib-weeks" role="img" aria-label={`${data.total} workspace contributions`}>
70
+ {data.weeks.map((week, wi) => (
71
+ <div key={wi} className="workspace-contrib-week">
72
+ {week.days.map((day) => (
73
+ <button
74
+ key={day.date}
75
+ type="button"
76
+ className={"workspace-contrib-cell lvl-" + (day.future ? "future" : day.level) + (day.count > 0 ? " has-count" : "")}
77
+ disabled={day.future}
78
+ data-count={day.count}
79
+ aria-label={day.future ? "" : `${day.count} on ${formatDay(day.date)}`}
80
+ onMouseEnter={(e) => {
81
+ if (day.future) return;
82
+ const r = e.currentTarget.getBoundingClientRect();
83
+ setHover({ date: day.date, count: day.count, x: r.left + r.width / 2, y: r.top });
84
+ }}
85
+ onMouseLeave={() => setHover(null)}
86
+ onClick={() => { if (!day.future && onSelectDay) onSelectDay(day.date); }}
87
+ />
88
+ ))}
89
+ </div>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ {hover ? (
96
+ <div
97
+ className="workspace-contrib-tooltip"
98
+ style={{ left: hover.x, top: hover.y }}
99
+ role="tooltip"
100
+ >
101
+ <span className="workspace-contrib-tooltip-count">
102
+ {hover.count} contribution{hover.count === 1 ? "" : "s"}
103
+ </span>
104
+ <span className="workspace-contrib-tooltip-date">{formatDay(hover.date)}</span>
105
+ {hover.count > 0 && buildDayHref ? (
106
+ <a
107
+ className="workspace-contrib-tooltip-link"
108
+ href={buildDayHref(hover.date)}
109
+ target="_blank"
110
+ rel="noopener noreferrer"
111
+ >
112
+ Open day in Workspace Lens ↗
113
+ </a>
114
+ ) : null}
115
+ </div>
116
+ ) : null}
117
+ </section>
118
+ );
119
+ }