chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
21
packages/core-analytics/src/analytics.interface.ts
Normal file
21
packages/core-analytics/src/analytics.interface.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type AnalyticsAttributeValue = string | number | boolean;
|
||||
|
||||
export type AnalyticsUser = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface IAnalytics {
|
||||
track(
|
||||
event: string,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
identify(
|
||||
user: AnalyticsUser,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
pageView(
|
||||
path: string,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
8
packages/core-analytics/src/index.ts
Normal file
8
packages/core-analytics/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
AnalyticsAttributeValue,
|
||||
AnalyticsUser,
|
||||
IAnalytics,
|
||||
} from "./analytics.interface";
|
||||
export { NoopAnalytics } from "./noop-analytics";
|
||||
export type { Analyzed } from "./with-analytics";
|
||||
export { withAnalytics } from "./with-analytics";
|
||||
54
packages/core-analytics/src/noop-analytics.test.ts
Normal file
54
packages/core-analytics/src/noop-analytics.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NoopAnalytics } from "@/noop-analytics";
|
||||
|
||||
describe("NoopAnalytics", () => {
|
||||
it("track() does not throw with event name only", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() => analytics.track("page_viewed")).not.toThrow();
|
||||
});
|
||||
|
||||
it("track() does not throw with event name and attributes", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() =>
|
||||
analytics.track("button_clicked", {
|
||||
label: "signup",
|
||||
count: 1,
|
||||
active: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("identify() does not throw with user only", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() => analytics.identify({ id: "user-123" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("identify() does not throw with user and attributes", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() =>
|
||||
analytics.identify({ id: "user-123" }, { plan: "pro", trial: false }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("pageView() does not throw with path only", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() => analytics.pageView("/dashboard")).not.toThrow();
|
||||
});
|
||||
|
||||
it("pageView() does not throw with path and attributes", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() =>
|
||||
analytics.pageView("/dashboard", { referrer: "/home", duration: 120 }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("flush() resolves without throwing", async () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
await expect(analytics.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("flush() returns a Promise", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(analytics.flush()).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
23
packages/core-analytics/src/noop-analytics.ts
Normal file
23
packages/core-analytics/src/noop-analytics.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
AnalyticsAttributeValue,
|
||||
AnalyticsUser,
|
||||
IAnalytics,
|
||||
} from "./analytics.interface";
|
||||
|
||||
export class NoopAnalytics implements IAnalytics {
|
||||
track(
|
||||
_event: string,
|
||||
_attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void {}
|
||||
identify(
|
||||
_user: AnalyticsUser,
|
||||
_attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void {}
|
||||
pageView(
|
||||
_path: string,
|
||||
_attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void {}
|
||||
flush(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, renderHook } from "@testing-library/react";
|
||||
import { RecordingAnalytics } from "@repo/core-testing";
|
||||
import {
|
||||
AnalyticsContextError,
|
||||
AnalyticsProvider,
|
||||
useAnalytics,
|
||||
} from "@/react/index";
|
||||
|
||||
function Tracker() {
|
||||
const analytics = useAnalytics();
|
||||
analytics.track("test.event");
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("AnalyticsProvider", () => {
|
||||
it("makes analytics available through context and track flows through", () => {
|
||||
const recording = new RecordingAnalytics();
|
||||
|
||||
render(
|
||||
<AnalyticsProvider value={recording}>
|
||||
<Tracker />
|
||||
</AnalyticsProvider>,
|
||||
);
|
||||
|
||||
expect(recording.tracked).toContainEqual({ event: "test.event" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAnalytics", () => {
|
||||
it("throws AnalyticsContextError when called outside a provider", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
expect(() => renderHook(() => useAnalytics())).toThrow(
|
||||
AnalyticsContextError,
|
||||
);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
33
packages/core-analytics/src/react/analytics-provider.tsx
Normal file
33
packages/core-analytics/src/react/analytics-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { IAnalytics } from "../analytics.interface";
|
||||
|
||||
const AnalyticsContext = createContext<IAnalytics | null>(null);
|
||||
|
||||
export class AnalyticsContextError extends Error {
|
||||
constructor() {
|
||||
super("useAnalytics() must be called within an <AnalyticsProvider>.");
|
||||
this.name = "AnalyticsContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export function AnalyticsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: IAnalytics;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AnalyticsContext.Provider value={value}>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnalytics(): IAnalytics {
|
||||
const analytics = useContext(AnalyticsContext);
|
||||
if (analytics === null) {
|
||||
throw new AnalyticsContextError();
|
||||
}
|
||||
return analytics;
|
||||
}
|
||||
5
packages/core-analytics/src/react/index.ts
Normal file
5
packages/core-analytics/src/react/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AnalyticsProvider,
|
||||
useAnalytics,
|
||||
AnalyticsContextError,
|
||||
} from "./analytics-provider";
|
||||
55
packages/core-analytics/src/with-analytics.test.ts
Normal file
55
packages/core-analytics/src/with-analytics.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||
import { withAnalytics, type Analyzed } from "@/with-analytics";
|
||||
import type { IAnalytics } from "@/analytics.interface";
|
||||
import { isAnalyzed } from "@repo/core-shared/conformance";
|
||||
|
||||
function makeAnalytics(): IAnalytics {
|
||||
return {
|
||||
track: () => undefined,
|
||||
identify: () => undefined,
|
||||
pageView: () => undefined,
|
||||
flush: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("withAnalytics", () => {
|
||||
it("returns an Analyzed<F>", () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async (_input: { id: string }) => ({ ok: true });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
expectTypeOf(wrapped).toMatchTypeOf<Analyzed<typeof fn>>();
|
||||
});
|
||||
|
||||
it("attaches __analyzed as a non-enumerable property on the wrapped function", () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async () => ({ ok: true });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
expect(isAnalyzed(wrapped)).toBe(true);
|
||||
expect(Object.keys(wrapped)).not.toContain("__analyzed");
|
||||
});
|
||||
|
||||
it("does NOT pollute the original input function with the brand", () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async () => ({ ok: true });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
expect(isAnalyzed(fn)).toBe(false);
|
||||
expect(wrapped).not.toBe(fn);
|
||||
});
|
||||
|
||||
it("passes input and output through unchanged", async () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
const result = await wrapped({ id: "abc" });
|
||||
expect(result).toEqual({ ok: true, id: "abc" });
|
||||
});
|
||||
|
||||
it("propagates errors", async () => {
|
||||
const analytics = makeAnalytics();
|
||||
const err = new Error("boom");
|
||||
const wrapped = withAnalytics(analytics, async () => {
|
||||
throw err;
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe(err);
|
||||
});
|
||||
});
|
||||
35
packages/core-analytics/src/with-analytics.ts
Normal file
35
packages/core-analytics/src/with-analytics.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IAnalytics } from "./analytics.interface";
|
||||
import { attachBrand } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* Phantom-type brand attached at wrap time by `withAnalytics`. The conformance
|
||||
* system uses this as the type-level seam for use cases that declare
|
||||
* `analyticsEvents: [...]` in their manifest — without `__analyzed`, the
|
||||
* binding is not assignable to `ProductionUseCase<I, O, M>` when M demands it.
|
||||
* At runtime the brand is a non-enumerable property attached by `attachBrand`
|
||||
* from `@repo/core-shared/conformance`, so the boot-time assertion can verify
|
||||
* the binding went through the analytics-aware path.
|
||||
*/
|
||||
export type Analyzed<F> = F & { readonly __analyzed: true };
|
||||
|
||||
/**
|
||||
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure
|
||||
* that forwards to `fn` unchanged and carries the `__analyzed` brand. The
|
||||
* forward closure (instead of returning `fn` directly) keeps the brand on
|
||||
* a fresh function so the caller's original `fn` is not mutated — important
|
||||
* when the same factory output is used elsewhere unwrapped (dev-seed paths,
|
||||
* tests).
|
||||
*/
|
||||
export function withAnalytics<Args extends unknown[], R>(
|
||||
// The wrapper attaches the brand and ensures the analytics dependency is
|
||||
// available at bind time. Actual `analytics.track()` calls live in the
|
||||
// use case body — only the use case knows which properties to extract
|
||||
// from its input/output for the analytics event.
|
||||
analytics: IAnalytics,
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): Analyzed<(...args: Args) => Promise<R>> {
|
||||
void analytics;
|
||||
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
|
||||
attachBrand(wrapped, "__analyzed");
|
||||
return wrapped as Analyzed<(...args: Args) => Promise<R>>;
|
||||
}
|
||||
Reference in New Issue
Block a user