diff --git a/apps/webapp/package.json b/apps/webapp/package.json index bc0492fe9f..2fe4d11b5c 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -29,7 +29,9 @@ "seed:reporting-demo": "dotenv -e ../../.env -- tsx scripts/seed-reporting-demo.ts", "seed:reporting-demo:staging": "dotenv -e ../../.env.staging -- tsx scripts/seed-reporting-demo.ts", "clean:reporting-demo": "dotenv -e ../../.env -- tsx scripts/clean-reporting-demo.ts", - "clean:reporting-demo:staging": "dotenv -e ../../.env.staging -- tsx scripts/clean-reporting-demo.ts" + "clean:reporting-demo:staging": "dotenv -e ../../.env.staging -- tsx scripts/clean-reporting-demo.ts", + "report:state-of-em-2026": "dotenv -e ../../.env -- tsx scripts/state-of-em-2026.ts", + "report:state-of-em-2026:staging": "dotenv -e ../../.env.staging -- tsx scripts/state-of-em-2026.ts" }, "dependencies": { "@bwip-js/browser": "^4.9.0", diff --git a/apps/webapp/scripts/state-of-em-2026.ts b/apps/webapp/scripts/state-of-em-2026.ts new file mode 100644 index 0000000000..cd805c966e --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026.ts @@ -0,0 +1,279 @@ +/** + * State of Equipment Management 2026 — Aggregate Extraction (orchestrator). + * + * Trimmed in v1.1 to 8 prioritized stats, then pivoted in v1.2 to use + * IDLE-asset telemetry (universal `ActivityEvent` signal) as the headline + * rather than ghost-asset telemetry (Audits add-on subset). The orchestrator + * calls three query modules — visibility, audits, disorder — plus the + * bookings module that was un-deferred in v1.2 for the late-return stat. + * + * v1.2 also introduces a `--probe` mode that runs FIRST. The probe checks + * feature-adoption rates against published thresholds (audits enabled, audits + * run, bookings activity, Asset.valuation coverage, anonymous-scan capability) + * so the data team can decide which stats survive to publication before any + * query is implemented. See ./state-of-em-2026/probe.ts. + * + * @see ./state-of-em-2026/README.md — workflow + the trimmed-scope explanation + * @see ./state-of-em-2026/methodology.md — published methodology + * @see ./state-of-em-2026/probe.ts — feature-adoption probe + */ + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { createDatabaseClient } from "@shelf/database"; + +import { + HelpRequested, + parseExtractorArgs, + USAGE, + type ExtractorCliOptions, +} from "./state-of-em-2026/cli"; +import { buildEligibleCohort } from "./state-of-em-2026/cohort"; +import { stripRawValues } from "./state-of-em-2026/anonymize"; +import { + buildEmptyOutput, + type ExtractorOutput, +} from "./state-of-em-2026/output-schema"; +import type { ExtractorContext } from "./state-of-em-2026/context"; +import { runProbe, printProbeSummary } from "./state-of-em-2026/probe"; +import { runVisibilityQueries } from "./state-of-em-2026/queries/visibility"; +import { runAuditsQueries } from "./state-of-em-2026/queries/audits"; +import { runBookingsQueries } from "./state-of-em-2026/queries/bookings"; +import { runDisorderQueries } from "./state-of-em-2026/queries/disorder"; +// DEFERRED in v1 — retained in repo for 2027 restoration: +// import { runCustodyQueries } from "./state-of-em-2026/queries/custody"; +// import { runIndustryQueries } from "./state-of-em-2026/queries/industries"; +// import { runTopPerformerQueries } from "./state-of-em-2026/queries/top-performers"; + +/** Stable identifier for this dataset — matches website frontmatter. */ +const DATASET_KEY = "soem-2026-v1"; +/** Methodology version — bump in lockstep with ./state-of-em-2026/methodology.md */ +const METHODOLOGY_VERSION = "1.2"; +/** Script version for output traceability. Bump on any logic change. */ +const SCRIPT_VERSION = "0.3.0"; + +async function main(): Promise { + const options = parseOptionsOrExit(); + + // Production guard — mirrors seed-reporting-demo.ts pattern. + if ( + process.env.NODE_ENV === "production" && + !options.iKnowWhatImDoing + ) { + console.error( + "\nRefusing to run with NODE_ENV=production without --i-know-what-im-doing.\n" + + "This script reads from the production database — confirm intent and try again.\n", + ); + process.exit(2); + } + + printRunHeader(options); + + const db = createDatabaseClient(); + + try { + await db.$connect(); + + // 1. Build the eligible cohort. + console.log("\nBuilding eligible-workspace cohort…"); + const { orgIds, summary } = await buildEligibleCohort(db, options); + console.log( + ` ${summary.totalEligible} baseline orgs → ${summary.finalCohortSize} after filters` + + ` (${summary.excludedByAssetCount} excluded by <${options.minAssets} assets,` + + ` ${summary.excludedByAllowlist} excluded by allowlist).` + + `\n Total assets in cohort: ${summary.totalAssets.toLocaleString()}.`, + ); + + if (orgIds.length < options.minCohortSize) { + console.error( + `\nCohort size ${orgIds.length} is below --min-cohort-size ${options.minCohortSize}.\n` + + "Refusing to run — the entire report would be unreportable.\n", + ); + process.exit(3); + } + + const ctx: ExtractorContext = { + db, + options, + dataWindowStart: options.dataWindowStart, + dataWindowEnd: options.dataWindowEnd, + minCohortSize: options.minCohortSize, + eligibleOrgIds: orgIds, + cohortSummary: summary, + }; + + // 2a. Probe mode: run only the feature-adoption probe and exit. + if (options.probe) { + console.log("\nRunning feature-adoption probe (no aggregates will be computed)…"); + const probe = await runProbe(db, ctx); + printProbeSummary(probe); + + if (options.dryRun) { + console.log("\n--dry-run + --probe: skipping probe file write.\n"); + return; + } + + const probePath = join(dirname(options.outputPath), "probe.json"); + await mkdir(dirname(probePath), { recursive: true }); + await writeFile(probePath, JSON.stringify(probe, null, 2) + "\n", "utf8"); + console.log(`\nWrote ${probePath}\n`); + return; + } + + // 2. Build the output skeleton. + const output = buildEmptyOutput({ + datasetKey: DATASET_KEY, + methodologyVersion: METHODOLOGY_VERSION, + dataWindowStart: options.dataWindowStart, + dataWindowEnd: options.dataWindowEnd, + scriptVersion: SCRIPT_VERSION, + cohort: summary, + }); + + // 3. Run the v1.2 query sections. + // The published structure is one universal-telemetry headline + // (idle assets in dollars) + supporting universal stats + a + // qualified audit-enabled subset section. The survey-derived + // stat (the 8th) is plugged into the website data file manually + // after the external survey tool collects responses; it is not + // produced by this script. + await runSection("Visibility", () => runVisibilityQueries(db, ctx), output); + await runSection("Audits (subset stats)", () => runAuditsQueries(db, ctx), output); + await runSection("Bookings", () => runBookingsQueries(db, ctx), output); + await runSection("Cost of disorder (idle headline + ghost subset + recovery)", () => runDisorderQueries(db, ctx), output); + + // DEFERRED v1.2: custody (history), industries, top-performer + // patterns. The query stubs remain in the queries/ directory for + // restoration in 2027. + + // 4. Industry cuts — empty in v1 (deferred to 2027 when sample is large). + output.industries = {}; + + // 5. Defense-in-depth: strip raw values before writing. + for (const key of Object.keys(output.aggregates)) { + output.aggregates[key] = stripRawValues(output.aggregates[key]); + } + + // 6. Summarize status counts. + printStatusSummary(output); + + // 7. Write the output (unless --dry-run). + if (options.dryRun) { + console.log("\n--dry-run passed: skipping file writes.\n"); + return; + } + + await writeOutput(output, options.outputPath); + console.log(`\nWrote ${options.outputPath}\n`); + + // 8. (Optional) CSV companion. Not implemented in v1. + if (options.csvPath) { + console.warn( + `\nNote: --csv was passed (${options.csvPath}) but CSV emission is not yet implemented.\n` + + "See state-of-em-2026/output-schema.ts to add it.\n", + ); + } + + console.log( + "Reminder: the survey-derived stat (survey_hours_lost_per_month_median)\n" + + "is plugged into the website data file manually after the external\n" + + "survey tool collects responses. See\n" + + "content/reports/research-inputs/survey-design.md on the website-v2 PR.\n", + ); + } finally { + await db.$disconnect(); + } +} + +function parseOptionsOrExit(): ExtractorCliOptions { + try { + return parseExtractorArgs(process.argv.slice(2)); + } catch (err) { + if (err instanceof HelpRequested) { + console.log(USAGE); + process.exit(0); + } + console.error( + `\nError: ${err instanceof Error ? err.message : String(err)}\n`, + ); + console.error(USAGE); + process.exit(1); + } +} + +async function runSection>( + label: string, + runner: () => Promise, + output: ExtractorOutput, +): Promise { + console.log(`\nRunning ${label}…`); + try { + const results = await runner(); + Object.assign(output.aggregates, results); + const okCount = Object.values(results).filter( + (v: any) => v && typeof v === "object" && (v as any).status === "ok", + ).length; + const totalCount = Object.keys(results).length; + console.log(` ${okCount}/${totalCount} aggregates ready.`); + } catch (err) { + console.error( + ` Section "${label}" failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} + +async function writeOutput( + output: ExtractorOutput, + path: string, +): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(output, null, 2) + "\n", "utf8"); +} + +function printRunHeader(options: ExtractorCliOptions): void { + const mode = options.dryRun ? "DRY RUN" : "LIVE RUN"; + console.log( + `\n=== State of Equipment Management 2026 — extraction (${mode}) ===\n` + + `Data window: ${options.dataWindowStart.toISOString().slice(0, 10)} → ` + + `${options.dataWindowEnd.toISOString().slice(0, 10)}\n` + + `Output: ${options.outputPath}\n` + + `Min assets: ${options.minAssets} per workspace\n` + + `Min cohort size: ${options.minCohortSize}\n` + + `Allowlist: ${options.internalAllowlistPath}\n` + + `Methodology: v${METHODOLOGY_VERSION}\n` + + `Script: v${SCRIPT_VERSION}\n` + + `Scope: v1.2 — idle-asset headline (universal telemetry)\n` + + (options.probe ? `Mode: PROBE-ONLY (no aggregates)\n` : ""), + ); +} + +function printStatusSummary(output: ExtractorOutput): void { + const counts = { ok: 0, cohort_too_small: 0, not_implemented: 0 }; + for (const agg of Object.values(output.aggregates)) { + counts[agg.status] += 1; + } + console.log( + "\n=== Aggregate status summary ===\n" + + ` ok ${counts.ok}\n` + + ` cohort_too_small ${counts.cohort_too_small}\n` + + ` not_implemented ${counts.not_implemented}\n`, + ); + if (counts.not_implemented > 0) { + console.warn( + `Warning: ${counts.not_implemented} aggregates are still stubs. ` + + "Implement them in apps/webapp/scripts/state-of-em-2026/queries/ before publication.\n", + ); + } +} + +main().catch((err) => { + console.error( + "\nExtraction failed:\n", + err instanceof Error ? err.stack ?? err.message : err, + "\n", + ); + process.exit(1); +}); diff --git a/apps/webapp/scripts/state-of-em-2026/README.md b/apps/webapp/scripts/state-of-em-2026/README.md new file mode 100644 index 0000000000..08d1033dcd --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/README.md @@ -0,0 +1,195 @@ +# State of Equipment Management 2026 — Extraction Kit + +This directory holds the data-extraction script for the **State of Equipment Management 2026** industry report published on `shelf.nu`. The script aggregates anonymized telemetry from the production Shelf database into a single JSON file that the marketing team copies into the public report. + +**Public report:** [shelf.nu/reports/state-of-equipment-management-2026](https://www.shelf.nu/reports/state-of-equipment-management-2026) + +**Companion website PR (where the JSON values land):** `shelf-nu/website-v2#142` + +--- + +## Scope (v1.2) + +The report has been through two editorial pivots: + +- **v1.0 → v1.1** — Cut from 30-stat "comprehensive" scaffold to 8 prioritized stats organized around one viral headline (ghost-asset dollar value). +- **v1.1 → v1.2** — Pivoted the headline from ghost-asset dollar value (depended on the paid Audits add-on) to **idle-asset dollar value** (universal `ActivityEvent` telemetry; no paid-feature dependency). Ghost-asset stats survive as a properly-qualified audit-enabled subset finding. + +The v1.2 pivot exists because honest CEO risk-management asked the right question: if a feature has bounded adoption, framing "median workspace's value of X" as a platform stat is dishonest when X depends on that feature. The new headline survives even if Audits adoption is low. + +**The 8 stats:** + +| # | Key | Source | Cohort | +|---|---|---|---| +| 1 | `ds_idle_asset_dollar_value_median_workspace` (**THE HEADLINE**) | `queries/disorder.ts` | Universal (all eligible orgs) | +| 2 | `ds_idle_asset_rate` | `queries/disorder.ts` | Universal | +| 3 | `pct_assets_with_active_custody` | `queries/visibility.ts` | Universal | +| 4 | `bk_pct_returned_late` | `queries/bookings.ts` | Bookings-using subset | +| 5 | `ds_recovery_dollar_value_total` | `queries/disorder.ts` | Universal (requires anonymous-scan detection) | +| 6 | `ds_ghost_asset_rate` | `queries/disorder.ts` | **Audit-enabled subset (qualified)** | +| 7 | `au_pct_audited_assets_missing` | `queries/audits.ts` | **Audit-enabled subset (qualified)** | +| 8 | `survey_hours_lost_per_month_median` | external survey tool | survey respondents | + +7 of 8 come from this script; the 8th is plugged in manually after the external admin survey runs (see `content/reports/research-inputs/survey-design.md` on the website-v2 PR). + +--- + +## What this script does + +1. Parses CLI arguments (data window, output path, dry-run, probe mode, internal allowlist). +2. Builds an **eligible-workspace cohort** per the published methodology: + - `Organization.type = TEAM` + - `Organization.workspaceDisabled = false` + - Owner `User.deletedAt IS NULL` + - `Organization.id NOT IN` the internal staff/demo allowlist + - `>= 10 assets` tracked over the data window +3. **`--probe` mode (run this FIRST)** — measures feature adoption (audits enabled, audits run, bookings active, valuation coverage, anonymous-scan capability) against published thresholds and writes a probe report. Tells you which stats survive to publication BEFORE you implement queries. See `./probe.ts`. +4. Otherwise — runs the v1.2 query modules (`visibility`, `audits`, `bookings`, `disorder`). +5. Applies the **anonymization layer**: + - **K-anonymity floor**: every aggregate must include >= N=20 workspaces (configurable). Sub-cohorts (audit-enabled, bookings-using) apply the floor independently. + - **One significant figure rounding**. +6. Writes the result to a JSON file matching the typed structure the website expects. +7. (Future) Writes a companion CSV for publication alongside the report. + +--- + +## Quick start + +From the monorepo root: + +```bash +# Step 1 (DO THIS FIRST) — feature-adoption probe: +pnpm webapp:report:state-of-em-2026 -- --probe + +# Step 2 — dry run to verify cohort + surface unimplemented queries: +pnpm webapp:report:state-of-em-2026 -- --dry-run + +# Step 3 — full extraction: +pnpm webapp:report:state-of-em-2026 -- --output ./output/aggregates.json +``` + +Full flag reference: see `cli.ts`. + +--- + +## Why `--probe` runs first + +The probe is the v1.2 risk-discipline layer. + +It runs five measurements against the eligible cohort: + +1. **Audits add-on enabled** — how many Team workspaces have the paid Audits feature on? Editorial context for the audit-subset narrative. +2. **Audits actually run in window** — how many of those workspaces ran a COMPLETED audit? Gates `ds_ghost_asset_rate` and `au_pct_audited_assets_missing`. +3. **Bookings activity in window** — how many workspaces used bookings? Gates `bk_pct_returned_late`. +4. **`Asset.valuation` coverage** — what % of cohort assets have a workspace-entered valuation? Below 30%, dollar headlines convert to percentage headlines. +5. **Anonymous-scan capability** — does `Scan.userId IS NULL` produce a usable signal for Found-via-Scan recovery? Gates `ds_recovery_dollar_value_total`. + +Each measurement is compared against a published threshold (see `probe.ts`, `ADOPTION_THRESHOLDS`). The probe writes a JSON report listing which stats survive, which need qualification, and which should be dropped from v1 entirely. **The website MDX is updated to match the probe's recommendations before the aggregates are computed** — that's the workflow. + +This is the discipline that earns the citation. A report that openly publishes "we measured audit-feature adoption at X%, dropped the headline that depended on it, and pivoted to universal telemetry" is materially more citable than one that quietly published the headline anyway. + +--- + +## Implementing the query modules + +Four files to implement, in priority order: + +1. **`queries/disorder.ts`** — the most important file. Contains the headline idle stat, the rate companion, recovery dollars, and the demoted ghost-asset rate. Idle-asset detection is a LEFT JOIN over `ActivityEvent` (and `Scan` as a fallback signal); likely cleaner as raw SQL than Prisma ORM. Ghost-asset detection is a window function over `AuditAsset` rows ordered by `AuditSession.startedAt` per asset. + +2. **`queries/audits.ts`** — one subset stat (`au_pct_audited_assets_missing`). The probe should have already confirmed audit-run rate clears the threshold; if not, this stat should be removed before publication. + +3. **`queries/bookings.ts`** — un-deferred in v1.2 for one stat (`bk_pct_returned_late`). Booking model has no `actualReturnAt` column; reconstruct from `ActivityEvent` BOOKING_CHECKED_IN. + +4. **`queries/visibility.ts`** — one universal stat (`pct_assets_with_active_custody`). Straightforward. + +Each query function uses `reportable({ ... })` from `../anonymize.ts` to wrap results. Do not construct `ReportableAggregate` directly — the wrapper enforces the k-anonymity check + sig-fig rounding. + +Estimated engineering: **~30 hours** across the four files. `disorder.ts` is the bulk (≈15h), with the idle LEFT JOIN being the new piece; `bookings.ts` is ≈6h; `audits.ts` is ≈5h; `visibility.ts` is ≈4h. + +--- + +## Production run guidance + +This script needs the real customer data to produce industry-wide aggregates. **No read-replica connection string exists in the codebase today.** Three options: + +1. **Run against a fresh staging clone of production data.** Cleanest — zero load on production. +2. **Add `DATABASE_URL_REPLICA` env var** + one-line change in `state-of-em-2026.ts` to use it when present. +3. **Run directly against production** with `--i-know-what-im-doing`. Read-only, paginated, off-peak. + +The production guard refuses to run with `NODE_ENV=production` unless `--i-know-what-im-doing` is passed. + +--- + +## Workflow: data team handoff to marketing + +``` +1. Populate `allowlist/internal-orgs.json` with Shelf staff / demo workspace IDs. + +2. Probe feature adoption to know which stats survive v1.2: + pnpm webapp:report:state-of-em-2026 -- --probe + + Read the probe output. For every stat marked DROP, update the website MDX + on website-v2#142 to remove or down-weight the corresponding section. + For every stat marked QUALIFY, confirm the qualification text is present + in the MDX prose. + +3. Dry-run to verify cohort size + surface unimplemented queries: + pnpm webapp:report:state-of-em-2026 -- --dry-run + +4. Implement the v1.2 query modules (disorder, audits, bookings, visibility). + Re-run dry-run after each to confirm aggregates flip from + `not_implemented` to `ok`. + +5. Real extraction: + pnpm webapp:report:state-of-em-2026 -- --output ./output/aggregates.json + +6. Copy 7 values from `aggregates.json` into the website-v2 PR's + `src/data/state-of-equipment-management-2026.ts` data file. + +7. Plug the 8th value (survey_hours_lost_per_month_median) from the + external survey tool result. + +8. Marketing runs customer outreach for the 3 blocks. + +9. Designer produces cover image + PDF layout + 3 inline data-viz images. + +10. Marketing picks one external benchmark per + `content/reports/research-inputs/external-benchmarks.md`. + +11. Editorial: flip `seo.noindex: true → false` in the MDX, remove the + publication note. + +12. Publish. +``` + +--- + +## Security and review notes + +This script does cross-organization aggregation, which is unusual in the Shelf codebase. Things to keep clean: + +- **No customer data ever leaves an aggregate.** Aggregates only — no names, titles, emails. +- **Each query gets its own k-anonymity check.** The audits-enabled sub-cohort, the bookings-using sub-cohort, the assets-with-valuation sub-cohort, etc. +- **`reportable()` is mandatory.** Direct `ReportableAggregate` construction bypasses safety; flag in code review. +- **Round before output.** Rounding in the script means the JSON itself doesn't carry leakable precision. +- **No raw SQL except via Prisma's `$queryRaw` with parameterized inputs.** The idle and ghost-asset queries may require raw SQL — parameterize. +- **Probe output is internal.** `probe.json` reveals feature-adoption rates that we deliberately do not publish. Treat it as internal data; do not commit it to the public repo, do not paste it into customer-facing materials. + +--- + +## What was deferred to 2027 + +The v1.1 editorial review (Musk-mode critique) cut these from the published report. v1.2 added `bookings.ts` back for one stat; the rest are still deferred: + +- **`queries/bookings.ts`** — un-deferred in v1.2 for `bk_pct_returned_late` only. The other six stats from v1.0 (conflict averted, lead time, peak day, overdue hours, etc.) remain deferred. +- **`queries/custody.ts`** — handover-rate stats were not in the top-8 priority list; the headline custody stat (`pct_assets_with_active_custody`) moved to `visibility.ts`. +- **`queries/industries.ts`** — deferred until the sample is large enough to break out by industry segment with confidence. +- **`queries/top-performers.ts`** — the stub itself admitted these were correlations, not causal claims. Either run a real difference-in-differences in 2027, or kill the section. + +The stub files remain in the repo. Restoring them is a matter of uncommenting the imports + `runSection` calls in `state-of-em-2026.ts`. + +--- + +## License + +The methodology and queries here are published in the open as a reproducibility artifact for the public report. CC BY 4.0, same as the report itself — see `./methodology.md`. diff --git a/apps/webapp/scripts/state-of-em-2026/allowlist/internal-orgs.json b/apps/webapp/scripts/state-of-em-2026/allowlist/internal-orgs.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/allowlist/internal-orgs.json @@ -0,0 +1 @@ +[] diff --git a/apps/webapp/scripts/state-of-em-2026/anonymize.ts b/apps/webapp/scripts/state-of-em-2026/anonymize.ts new file mode 100644 index 0000000000..17a95d1f83 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/anonymize.ts @@ -0,0 +1,150 @@ +/** + * Anonymization layer — k-anonymity floor + significant-figure rounding. + * + * The two safety controls every aggregate passes through before landing in + * the output JSON. Together they ensure no individual workspace is + * identifiable and no number leaks excessive precision. + * + * @see ../methodology.md — published methodology, anonymization section + */ + +/** + * Status flag attached to each aggregate. `ok` means the aggregate was + * computed and passes the cohort-size floor; `cohort_too_small` means the + * underlying cohort was below `minCohortSize` and the value has been nulled. + * The report MDX should treat `cohort_too_small` aggregates as "not + * reportable" and omit them from the published text. + */ +export type AggregateStatus = + | "ok" + | "cohort_too_small" + | "not_implemented"; + +/** + * Wrapper type returned by every query function. The orchestrator collects + * these into the final output JSON. + */ +export interface ReportableAggregate { + key: string; + label: string; + /** Rounded value, or null when cohort_too_small / not_implemented. */ + value: number | null; + /** + * Optional: same stat from the prior year. Used by the 2027 edition + * to render year-over-year trends. 2026 leaves this undefined; 2027 + * pulls the 2026 published value into this field per stat. + */ + priorYearValue?: number | null; + /** Unrounded value, kept for debugging. NEVER include in published output. */ + rawValue?: number; + /** Cohort size the aggregate was computed over. */ + cohortSize: number; + /** Status flag. */ + status: AggregateStatus; + /** Optional unit suffix for display (e.g. "%", " days", " USD"). */ + unit?: string; +} + +/** + * Round to one significant figure. Standard rule for published industry + * aggregates so the figure doesn't appear spuriously precise. + * + * Examples: + * roundToOneSigFig(3047) === 3000 + * roundToOneSigFig(0.34) === 0.3 + * roundToOneSigFig(72) === 70 + * roundToOneSigFig(0) === 0 + */ +export function roundToOneSigFig(n: number): number { + if (n === 0 || !Number.isFinite(n)) return n; + const sign = Math.sign(n); + const absN = Math.abs(n); + const magnitude = Math.pow(10, Math.floor(Math.log10(absN))); + return sign * Math.round(absN / magnitude) * magnitude; +} + +/** + * Round to a specific number of significant figures. Use sparingly — the + * default is one sig fig for a reason. Document the override. + */ +export function roundToSigFigs(n: number, sigFigs: number): number { + if (n === 0 || !Number.isFinite(n)) return n; + if (sigFigs <= 0) { + throw new Error(`sigFigs must be >= 1, got: ${sigFigs}`); + } + const sign = Math.sign(n); + const absN = Math.abs(n); + const magnitude = Math.pow(10, Math.floor(Math.log10(absN)) - (sigFigs - 1)); + return sign * Math.round(absN / magnitude) * magnitude; +} + +/** + * Wrap a computed numerical aggregate with the k-anonymity check and + * sig-fig rounding. The returned object is ready to embed in the output + * JSON. + * + * Use this for EVERY aggregate. Never write a raw `{ value, cohortSize }` + * directly to output — the wrapper enforces the safety policy. + */ +export function reportable(opts: { + key: string; + label: string; + rawValue: number; + cohortSize: number; + minCohortSize: number; + unit?: string; + /** Override the default 1-sig-fig rounding (rarely needed; document why). */ + sigFigs?: number; + /** Optional: prior year's value for trend rendering. */ + priorYearValue?: number | null; +}): ReportableAggregate { + const meetsFloor = opts.cohortSize >= opts.minCohortSize; + const value = meetsFloor + ? opts.sigFigs && opts.sigFigs !== 1 + ? roundToSigFigs(opts.rawValue, opts.sigFigs) + : roundToOneSigFig(opts.rawValue) + : null; + + return { + key: opts.key, + label: opts.label, + value, + ...(opts.priorYearValue !== undefined ? { priorYearValue: opts.priorYearValue } : {}), + rawValue: opts.rawValue, + cohortSize: opts.cohortSize, + status: meetsFloor ? "ok" : "cohort_too_small", + ...(opts.unit ? { unit: opts.unit } : {}), + }; +} + +/** + * Helper for stubbed queries — returns an aggregate with `not_implemented` + * status so the orchestrator can run end-to-end and surface what's missing. + */ +export function notImplementedAggregate(opts: { + key: string; + label: string; + unit?: string; +}): ReportableAggregate { + return { + key: opts.key, + label: opts.label, + value: null, + cohortSize: 0, + status: "not_implemented", + ...(opts.unit ? { unit: opts.unit } : {}), + }; +} + +/** + * Strip raw values before writing the output JSON. Defense-in-depth: even + * if a query module forgets to drop the raw value, the orchestrator wipes + * them before publication. + */ +export function stripRawValues( + agg: ReportableAggregate, +): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { rawValue: _unused, ...rest } = agg; + return rest; +} diff --git a/apps/webapp/scripts/state-of-em-2026/cli.ts b/apps/webapp/scripts/state-of-em-2026/cli.ts new file mode 100644 index 0000000000..c9b55aa3b9 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/cli.ts @@ -0,0 +1,207 @@ +/** + * CLI argument parsing for the State of Equipment Management 2026 + * extraction script. + * + * Mirrors the convention used by `seed-reporting-demo/cli.ts` — a tiny + * hand-written parser with explicit usage text, so the script has zero + * external CLI-parsing deps and the data team can read the parser to + * understand exactly what flags exist. + * + * @see ../README.md — full flag reference + * @see ../state-of-em-2026.ts — entry point + */ + +export class HelpRequested extends Error { + constructor() { + super("help requested"); + } +} + +/** + * Parsed CLI options. All flags have defaults so the script can be run + * with no arguments during local development. + */ +export interface ExtractorCliOptions { + /** Path to write the result JSON. */ + outputPath: string; + /** Optional path to write a companion CSV of the published aggregates. */ + csvPath?: string; + /** ISO 8601 start of the observation window (inclusive). */ + dataWindowStart: Date; + /** ISO 8601 end of the observation window (inclusive). */ + dataWindowEnd: Date; + /** Minimum assets per workspace for inclusion in the eligible cohort. */ + minAssets: number; + /** Minimum cohort size for an aggregate to be reported (k-anonymity floor). */ + minCohortSize: number; + /** Path to JSON file of org IDs to exclude (Shelf staff / demo workspaces). */ + internalAllowlistPath: string; + /** Whether to run the pipeline without writing output. */ + dryRun: boolean; + /** Required to run with NODE_ENV=production. */ + iKnowWhatImDoing: boolean; + /** + * Probe-only mode. Skips aggregate queries and writes a feature-adoption + * probe to `/probe.json` so the data team can verify that + * the v1.2 stat structure is defensible before queries are implemented. + * See ./probe.ts. + */ + probe: boolean; +} + +export const USAGE = ` +Usage: pnpm webapp:report:state-of-em-2026 [-- ] + +Flags: + --output Output JSON path. Default: ./output/aggregates.json + --csv Optional companion CSV path for publication. + --data-window-start ISO 8601 (YYYY-MM-DD). Default: 2025-05-01 + --data-window-end ISO 8601 (YYYY-MM-DD). Default: 2026-04-30 + --min-assets Minimum assets per workspace. Default: 10 + --min-cohort-size K-anonymity floor. Default: 20 + --internal-allowlist JSON file of org IDs to exclude. + Default: ./allowlist/internal-orgs.json + --dry-run Run the pipeline without writing output. + --probe Run only the feature-adoption probe. + Writes ./output/probe.json — no aggregates. + Use this FIRST to verify the v1.2 stat + structure is defensible against your data. + --i-know-what-im-doing Required for NODE_ENV=production. + --help Print this usage and exit. + +Examples: + # Step 1 — probe feature adoption to know which stats survive v1.2: + pnpm webapp:report:state-of-em-2026 -- --probe + + # Step 2 — dry run to verify cohort size and surface unimplemented queries: + pnpm webapp:report:state-of-em-2026 -- --dry-run + + # Step 3 — full run to local file: + pnpm webapp:report:state-of-em-2026 -- --output ./output/aggregates.json + + # Production run (with explicit acknowledgement): + NODE_ENV=production pnpm webapp:report:state-of-em-2026 -- \\ + --output ./output/aggregates.json --i-know-what-im-doing +`.trim(); + +const DEFAULTS = { + outputPath: "./output/aggregates.json", + dataWindowStart: "2025-05-01", + dataWindowEnd: "2026-04-30", + minAssets: 10, + minCohortSize: 20, + internalAllowlistPath: "./allowlist/internal-orgs.json", +} as const; + +/** + * Parse argv into typed options. Throws HelpRequested for `--help`, + * Error for malformed input. + */ +export function parseExtractorArgs(argv: string[]): ExtractorCliOptions { + const result: ExtractorCliOptions = { + outputPath: DEFAULTS.outputPath, + dataWindowStart: parseDate(DEFAULTS.dataWindowStart, "--data-window-start"), + dataWindowEnd: parseDate(DEFAULTS.dataWindowEnd, "--data-window-end"), + minAssets: DEFAULTS.minAssets, + minCohortSize: DEFAULTS.minCohortSize, + internalAllowlistPath: DEFAULTS.internalAllowlistPath, + dryRun: false, + iKnowWhatImDoing: false, + probe: false, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case "--help": + case "-h": + throw new HelpRequested(); + case "--output": + result.outputPath = requireValue(argv, ++i, "--output"); + break; + case "--csv": + result.csvPath = requireValue(argv, ++i, "--csv"); + break; + case "--data-window-start": + result.dataWindowStart = parseDate( + requireValue(argv, ++i, "--data-window-start"), + "--data-window-start", + ); + break; + case "--data-window-end": + result.dataWindowEnd = parseDate( + requireValue(argv, ++i, "--data-window-end"), + "--data-window-end", + ); + break; + case "--min-assets": + result.minAssets = parsePositiveInt( + requireValue(argv, ++i, "--min-assets"), + "--min-assets", + ); + break; + case "--min-cohort-size": + result.minCohortSize = parsePositiveInt( + requireValue(argv, ++i, "--min-cohort-size"), + "--min-cohort-size", + ); + break; + case "--internal-allowlist": + result.internalAllowlistPath = requireValue( + argv, + ++i, + "--internal-allowlist", + ); + break; + case "--dry-run": + result.dryRun = true; + break; + case "--probe": + result.probe = true; + break; + case "--i-know-what-im-doing": + result.iKnowWhatImDoing = true; + break; + default: + throw new Error(`Unknown flag: ${arg}`); + } + } + + if (result.dataWindowEnd <= result.dataWindowStart) { + throw new Error( + "--data-window-end must be after --data-window-start.", + ); + } + + return result; +} + +/** Helper: pull the value following a flag, or throw if missing. */ +function requireValue(argv: string[], index: number, flag: string): string { + const value = argv[index]; + if (!value || value.startsWith("--")) { + throw new Error(`Flag ${flag} requires a value.`); + } + return value; +} + +/** Parse a YYYY-MM-DD string to a Date, throwing on malformed input. */ +function parseDate(value: string, flag: string): Date { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new Error(`${flag} must be in YYYY-MM-DD format. Got: ${value}`); + } + const d = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(d.getTime())) { + throw new Error(`${flag} is not a valid date: ${value}`); + } + return d; +} + +/** Parse a positive integer, throwing on malformed input. */ +function parsePositiveInt(value: string, flag: string): number { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n <= 0 || String(n) !== value) { + throw new Error(`${flag} must be a positive integer. Got: ${value}`); + } + return n; +} diff --git a/apps/webapp/scripts/state-of-em-2026/cohort.ts b/apps/webapp/scripts/state-of-em-2026/cohort.ts new file mode 100644 index 0000000000..10c5ca6432 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/cohort.ts @@ -0,0 +1,159 @@ +/** + * Eligible-workspace cohort builder. + * + * Applies the report's published inclusion criteria: + * - `Organization.type = TEAM` (Personal workspaces excluded) + * - `Organization.workspaceDisabled = false` + * - Owner `User.deletedAt IS NULL` + * - Workspace ID NOT in internal allowlist + * - Workspace tracked >= minAssets assets during the data window + * + * The returned `eligibleOrgIds` array is the single cohort every downstream + * query restricts to. Per-query feature-enabled subsetting (e.g. Audits- + * enabled workspaces only) happens inside each query module. + * + * @see ../methodology.md — published methodology + * @see ./anonymize.ts — k-anonymity floor applied at output time + */ + +import { readFile } from "node:fs/promises"; +import type { ExtendedPrismaClient } from "@shelf/database"; + +import type { ExtractorCliOptions } from "./cli"; + +/** + * Diagnostic counts emitted alongside the cohort. Useful for the data team + * to verify their cohort matches expectations before they cite numbers. + */ +export interface CohortSummary { + /** Total Organizations matching the basic inclusion criteria. */ + totalEligible: number; + /** Of those, how many were excluded by the asset-count threshold. */ + excludedByAssetCount: number; + /** Of those, how many were excluded by the internal allowlist. */ + excludedByAllowlist: number; + /** Final cohort size after all filters. */ + finalCohortSize: number; + /** Distinct country count, approximated from owner locale if available. */ + countryCount: number; + /** Total assets tracked across the final cohort during the window. */ + totalAssets: number; + /** ISO 8601 timestamp the cohort was computed at. */ + computedAt: string; +} + +/** + * Returns the array of eligible Organization IDs, plus a diagnostic summary + * that the orchestrator includes in the output JSON for transparency. + */ +export async function buildEligibleCohort( + db: ExtendedPrismaClient, + options: ExtractorCliOptions, +): Promise<{ orgIds: string[]; summary: CohortSummary }> { + const allowlist = await loadAllowlist(options.internalAllowlistPath); + + // 1. Baseline eligibility query — the schema-level filters that we can + // express directly in a Prisma where clause. The asset-count threshold + // is applied in step 2 because Prisma doesn't support a `_count` + // comparison inside the where clause cleanly. + const baselineOrgs = await db.organization.findMany({ + where: { + type: "TEAM", + workspaceDisabled: false, + user: { + deletedAt: null, + }, + }, + select: { + id: true, + _count: { + select: { + assets: { + where: { + createdAt: { lte: options.dataWindowEnd }, + }, + }, + }, + }, + }, + }); + + // 2. Apply asset-count threshold and allowlist exclusion. + let excludedByAssetCount = 0; + let excludedByAllowlist = 0; + const finalOrgIds: string[] = []; + + for (const org of baselineOrgs) { + if (allowlist.has(org.id)) { + excludedByAllowlist += 1; + continue; + } + if (org._count.assets < options.minAssets) { + excludedByAssetCount += 1; + continue; + } + finalOrgIds.push(org.id); + } + + const totalAssets = baselineOrgs + .filter((o) => !allowlist.has(o.id) && o._count.assets >= options.minAssets) + .reduce((sum, o) => sum + o._count.assets, 0); + + // 3. Country count — placeholder. The DB doesn't store country directly; + // a real implementation would derive from `UserBusinessIntel.country`, + // billing country, or Stripe customer country. For v1 we report 0 + // here and the data team fills in once they decide on the source. + const countryCount = 0; + + return { + orgIds: finalOrgIds, + summary: { + totalEligible: baselineOrgs.length, + excludedByAssetCount, + excludedByAllowlist, + finalCohortSize: finalOrgIds.length, + countryCount, + totalAssets, + computedAt: new Date().toISOString(), + }, + }; +} + +/** + * Load the internal allowlist JSON file. The file format is a plain JSON + * array of organization IDs to exclude (Shelf staff workspaces, demo orgs, + * support workspaces). Missing file is treated as an empty allowlist — the + * data team is expected to maintain the real list. + */ +async function loadAllowlist(path: string): Promise> { + try { + const contents = await readFile(path, "utf8"); + const parsed = JSON.parse(contents); + if (!Array.isArray(parsed)) { + throw new Error( + `Internal allowlist at ${path} must be a JSON array of org IDs. Got: ${typeof parsed}`, + ); + } + if (parsed.some((v) => typeof v !== "string")) { + throw new Error( + `Internal allowlist at ${path} must contain only string IDs.`, + ); + } + return new Set(parsed); + } catch (err) { + // ENOENT is benign — we treat a missing file as an empty allowlist + // so the script runs out of the box for the data team's first run. + if ( + err instanceof Error && + "code" in err && + (err as { code: string }).code === "ENOENT" + ) { + console.warn( + `\nWarning: internal allowlist at ${path} not found. Proceeding with empty allowlist.\n` + + "Production runs MUST provide a populated allowlist excluding Shelf staff and demo workspaces.\n", + ); + return new Set(); + } + throw err; + } +} diff --git a/apps/webapp/scripts/state-of-em-2026/context.ts b/apps/webapp/scripts/state-of-em-2026/context.ts new file mode 100644 index 0000000000..8d2c74e9cc --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/context.ts @@ -0,0 +1,33 @@ +/** + * Shared extractor context. + * + * Built once in the entry point and threaded into every query module. Keeps + * each query function pure(ish) — it can read from the database via + * `ctx.db`, restrict to the data window via `ctx.dataWindowStart/End`, and + * scope to the eligible cohort via `ctx.eligibleOrgIds`. + * + * @see ./cohort.ts — how eligibleOrgIds is built + * @see ./cli.ts — how options arrive + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; + +import type { ExtractorCliOptions } from "./cli"; +import type { CohortSummary } from "./cohort"; + +export interface ExtractorContext { + /** Database client. The script holds one connection for the whole run. */ + db: ExtendedPrismaClient; + /** CLI options as parsed. */ + options: ExtractorCliOptions; + /** Start of the data window (inclusive). */ + dataWindowStart: Date; + /** End of the data window (inclusive). */ + dataWindowEnd: Date; + /** K-anonymity floor (mirror of options.minCohortSize for ergonomics). */ + minCohortSize: number; + /** Pre-computed list of eligible organization IDs. */ + eligibleOrgIds: string[]; + /** Summary of how the cohort was built — emitted with the output JSON. */ + cohortSummary: CohortSummary; +} diff --git a/apps/webapp/scripts/state-of-em-2026/methodology.md b/apps/webapp/scripts/state-of-em-2026/methodology.md new file mode 100644 index 0000000000..179c163db8 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/methodology.md @@ -0,0 +1,172 @@ +# Methodology — State of Equipment Management 2026 + +This file mirrors the published methodology section of the public report at [shelf.nu/reports/state-of-equipment-management-2026](https://www.shelf.nu/reports/state-of-equipment-management-2026#methodology). It is duplicated here so the extraction script and the published report cannot drift out of sync. + +When the report's methodology changes, **change it in both places in the same commit**. Bump the `methodologyVersion` string in both the report MDX frontmatter and this file's footer. + +--- + +## Data sources + +Two sources, paired: + +1. **Anonymized telemetry** from production Shelf workspaces. No customer-identifying data is used. All numbers in the report are aggregates over cohorts of at least **20 workspaces**; cohorts smaller than 20 are not reported. + +2. **A survey of approximately 200 workspace administrators**, distributed via email and in-app during the publication period. The survey is the source of the "operational tax" stat (median hours lost per month) and the qualitative themes in the report. + +The pairing is deliberate: telemetry says what; the survey says why. + +## Time window + +The 12 months ending **April 30, 2026** for telemetry (data window: 2025-05-01 through 2026-04-30). Survey responses were collected in the publication month. + +## Sample + +Approximately **{{TODO: workspaces}} workspaces**, **{{TODO: assets}} assets**, spanning **{{TODO: countries}} countries**, plus **{{TODO: ~200} survey responses**. + +## Inclusion criteria (telemetry) + +A workspace is included in the report if it meets all of the following at the end of the data window: + +- `Organization.type = TEAM` — Personal workspaces are excluded; they are individual sandboxes. +- `Organization.workspaceDisabled = false` — disabled workspaces don't contribute to the year's operational signal. +- Owner `User.deletedAt IS NULL` — workspaces whose owner has been soft-deleted are excluded. +- The workspace has tracked **at least 10 assets** during the data window. +- The workspace ID is **not on the internal allowlist** (`./allowlist/internal-orgs.json`). +- Where a stat requires a specific feature (e.g. audits), the workspace must have that feature enabled. + +## Anonymization + +- No customer names, workspace names, user names, asset titles, or location names appear in any aggregate. +- Numerical aggregates are rounded to **one significant figure** before output. +- Every aggregate must include at least **20 workspaces** in its cohort. Sub-cohort aggregates (e.g. audits-enabled subset) apply the same floor independently. +- Quotes attributed to specific customers in the report are published with that customer's explicit permission. Quotes from the survey's open-ended questions are only published with permission. + +## Risk disclosures + +This is the section that separates a research artifact from a marketing piece. v1.2 of the report explicitly publishes the risks it manages. + +### 1. Feature-adoption risk + +Several Shelf features that produce useful operational signal have bounded adoption among Team workspaces: + +- **Audits add-on.** A paid feature. Not every Team workspace has it enabled, and of those that do, not every workspace runs audits in any given year. +- **Bookings.** Not every Team workspace uses bookings — some workspaces use Shelf only for custody/QR tracking. + +The v1.2 published structure handles this risk explicitly: + +- **The headline stat (`ds_idle_asset_dollar_value_median_workspace`) is computed from universal telemetry** — `ActivityEvent` fires for every Shelf workspace, regardless of feature mix. No paid-feature dependency. The headline survives even if audit adoption is low. +- **Audit-derived stats are published only with the qualifier "audit-enabled subset only — N% of cohort"** — never presented as platform medians. +- **Bookings-derived stats are published only with the qualifier "among workspaces that used the bookings feature during the window"** — same discipline. + +The thresholds are published explicitly: + +| Adoption metric | Threshold | If below threshold | +|---|---|---| +| Audits add-on enabled | 5% of cohort | Drop the audit-subset section entirely | +| Audit sessions run in window | 3% of cohort | Drop ghost-rate and audited-missing-rate from report | +| Bookings activity in window | 10% of cohort | Drop the late-return stat | +| `Asset.valuation` coverage | 30% of cohort assets | Convert dollar headlines to percentage headlines | + +The extraction script's `--probe` mode (see `./probe.ts`) measures each rate against its threshold before any aggregate is computed. The published report records which stats survived the probe; those that didn't are listed in this section by name, not silently omitted. + +### 2. `Asset.valuation` coverage + +The valuation field is workspace-entered and partial. Approximately **{{TODO: pct_assets_with_valuation}}%** of tracked assets in the eligible cohort carry a valuation. Dollar figures are computed only over the assets with a valuation, then median-extrapolated per workspace. **The published dollar figures are conservative lower bounds** — they exclude assets without explicit valuation entirely. If overall coverage falls below the 30% threshold, the dollar headline is replaced with the percentage equivalent. + +### 3. Cohort-size enforcement (k-anonymity) + +Every aggregate requires a cohort of at least **20 workspaces**. Sub-cohort aggregates (e.g. the audit-enabled subset) apply the same floor independently — the extraction script does not "borrow" the global cohort size to push a small sub-cohort past the floor. + +The extraction script returns `cohort_too_small` for any stat whose underlying cohort falls below 20. Those stats are omitted from the published report, not silently rounded down. + +### 4. Survey response rate + +The survey targeted n=200 admins. Actual responses: **{{TODO: surveyResponses}}**. If responses had fallen below n=50, the survey-derived stat would have been dropped entirely. We disclose the response rate alongside the stat itself in the report copy. + +## Definitions + +Where a stat could be defined multiple ways, we picked the most conservative definition. + +### Idle asset (THE HEADLINE DEFINITION) + +An `Asset` with **no `ActivityEvent` of any action — scan, custody change, booking event, location update, audit scan — in the prior 90 days at end of window**. Assets created within the 90-day idle window are excluded — brand-new assets without history are not "idle", they're just new. + +This is the v1.2 headline because `ActivityEvent` is universal telemetry: every Shelf workspace produces these events, regardless of feature mix. Idle-asset measurement does not depend on any paid feature. + +The implementation also consults `Scan.createdAt` as a fallback signal — if an asset's QR was scanned in the 90-day window but no `ActivityEvent` was recorded (a possible signal-gap case), the asset is **not** counted as idle. This is the conservative bias. + +### Idle-asset dollar value (THE HEADLINE) + +For each workspace: sum `Asset.valuation` over the workspace's idle assets where valuation is set. The published figure (`ds_idle_asset_dollar_value_median_workspace`) is the median across those per-workspace sums. + +Median rather than mean because the distribution is right-skewed (a small number of workspaces with very high-value fleets would dominate a mean). + +### Active custody + +An asset has a `Custody` row (current custodian) at the moment of measurement. Historical custody transfers are tracked via `ActivityEvent` rows and could be used for handover-rate calculations in future editions; the v1 report uses only the current-state measurement. + +### Late return + +A `Booking` with one of the following conditions: + +1. Status is `COMPLETE` or `ARCHIVED` **and** the most recent `BOOKING_CHECKED_IN` `ActivityEvent` for the booking occurred after `Booking.to`, **or** +2. Status is `ONGOING` or `OVERDUE` **and** the current time is past `Booking.to`. + +Sub-cohort: workspaces that had at least one non-`DRAFT`, non-`CANCELLED` booking with `from` inside the data window. Apply k-anonymity to this sub-cohort independently. + +### Recovery via Found-via-Scan + +A `Scan` event from an anonymous scanner — identified by `Scan.userId IS NULL` — whose associated asset (via `Qr.assetId`) was previously marked Missing in an audit or Idle in the data window. The dollar total `ds_recovery_dollar_value_total` sums `Asset.valuation` over all recovered assets in the window across all workspaces — a single platform-wide number, not per-workspace. + +The `Scan` model does not expose an explicit "anonymous" boolean column; the signal is `userId IS NULL`. The `--probe` mode verifies this signal is present and produces a non-zero count before the recovery stat is committed to publication. + +### Ghost asset (audit-enabled subset definition) + +An `Asset` that: + +1. Exists in the workspace's asset inventory, +2. Was on the expected list of two or more consecutive `AuditAsset` rows with status `MISSING`, +3. Has had **no `AuditScan` or `Scan` event between those audits anywhere on the platform**. + +The last clause is what makes the definition useful: an asset that moves without a location update is not a ghost (it gets scanned at the new location). A ghost is an asset that has genuinely vanished from operational reality. + +**v1.2 scoping note:** the ghost-asset rate (`ds_ghost_asset_rate`) is published only as an **audit-enabled subset finding** — computed across workspaces that ran at least one COMPLETED audit in the window. It is never presented as a platform median. If the probe indicates audit-run rate below 3% of cohort, the stat is dropped from the published report entirely rather than published with a thin disclaimer. + +### Audit completion + +An `AuditSession` that reached the `COMPLETED` status within the data window. Used in the v1.2 audit-subset stat `au_pct_audited_assets_missing` (sum of `missingAssetCount` / sum of `expectedAssetCount` across sessions in the sub-cohort). + +## Confidence levels + +Each stat carries a confidence level. The extraction script attaches this label per stat in the output JSON, and the data file mirrors it. + +- **High** — derived from a comprehensive telemetry source covering the entire eligible cohort. +- **Medium** — derived from a partial telemetry source, opt-in feature usage, or a definition that involves a judgement call. +- **Low** — reported with the figure but flagged in copy; do not cite without checking the underlying definition. + +## Survey methodology + +- **Audience:** workspace administrators on the Team or Enterprise tier, active in the previous 30 days. +- **Distribution:** email + in-app banner. 2-week response window. No incentive offered. +- **Sample target:** n = 200 complete responses. +- **Questions:** 5 questions, single page. The full questionnaire is published as a separate PDF alongside the report so journalists and Wikipedia editors can verify the wording. +- **Demographic capture:** industry, workspace size band, subscription tier, workspace age — captured from workspace metadata at submission time, not asked of the respondent. +- **Anonymization:** survey responses are stored separately from telemetry. Aggregate before reporting. + +## Methodology version + +This methodology is published as **version 1.2**. v1.1 was the trimmed-to-8 ghost-asset-headline scaffold; v1.2 pivoted the headline to idle-asset telemetry (universal `ActivityEvent` signal) and demoted ghost-asset stats to a qualified audit-enabled subset finding. Subsequent annual editions will track methodology diffs; the 2027 edition will publish a "what changed" note alongside any version bump. + +## Reproducibility + +The extraction script that produced the telemetry aggregates is published in the open at [github.com/Shelf-nu/shelf.nu](https://github.com/Shelf-nu/shelf.nu) under `apps/webapp/scripts/state-of-em-2026/`. The script contains no customer data — only queries and aggregation logic. The `--probe` mode (see `./probe.ts`) is a separate diagnostic that verifies feature adoption before stats are computed, so the methodology can be re-validated by anyone with database access. Independent researchers can read the queries to verify the methodology matches the published numbers. + +## Limitations + +- **Self-hosted Shelf instances** are not included — the report reflects hosted-cloud usage only. Self-hosted patterns may differ. +- **`Asset.valuation` coverage is partial.** Dollar figures are conservative lower bounds; they exclude assets whose workspace did not enter a valuation. The methodology discloses the coverage percentage and the script's `--probe` mode flags it explicitly. +- **"Missing" in an audit context** is what was scanned-or-not-scanned during a specific audit session, not a permanent property of the asset. We use it as input to the ghost-asset definition, which requires the additional condition of no scan activity between audits. The ghost-asset rate itself is published only as an audit-enabled subset finding, never as a platform median. +- **Idle is per-asset-per-90-days, not permanent.** An idle asset in the data window may have been actively used in a previous window; the stat reflects the snapshot at end of window, not a permanent state of the asset. +- **Survey N is small.** ~200 responses is adequate for medians and reportable themes; it is not sufficient to break out reliably by industry. The 2027 survey will aim for n=500. +- **Sample skew.** The population is Shelf customers — teams that have already opted into a structured asset-management practice. Industry-wide rates of idle assets, late returns, and missing items are likely higher than what we observe. diff --git a/apps/webapp/scripts/state-of-em-2026/output-schema.ts b/apps/webapp/scripts/state-of-em-2026/output-schema.ts new file mode 100644 index 0000000000..484eb13cd2 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/output-schema.ts @@ -0,0 +1,79 @@ +/** + * Output JSON schema for the State of Equipment Management 2026 extraction. + * + * The script writes exactly one file matching this shape. The website-v2 + * data file (`src/data/state-of-equipment-management-2026.ts`) consumes + * matching keys. + * + * Scope note: in v1 the report pivoted to a single-headline structure + * (ghost-assets-in-dollars). The output schema is unchanged — it remains + * a flat key/value map of aggregates plus optional per-industry cuts — + * but the orchestrator calls fewer query modules. The schema is stable + * so the script and the website data file remain decoupled. + * + * @see ../README.md — workflow for transferring values into the website + * @see https://github.com/Shelf-nu/website-v2/blob/main/src/data/state-of-equipment-management-2026.ts + */ + +import type { ReportableAggregate } from "./anonymize"; +import type { CohortSummary } from "./cohort"; + +/** Top-level output written to the file passed via `--output`. */ +export interface ExtractorOutput { + /** Metadata that travels with the dataset. */ + metadata: ExtractorMetadata; + /** Cohort summary for transparency — published in the report's methodology. */ + cohort: CohortSummary; + /** All aggregates, keyed by stable identifier. */ + aggregates: Record; + /** Per-industry aggregates. Empty {} in v1 (industries deferred to 2027). */ + industries: Record; +} + +export interface ExtractorMetadata { + /** Stable report identifier. Mirrors `datasetKey` in the website frontmatter. */ + datasetKey: string; + /** Methodology version this output was produced under. */ + methodologyVersion: string; + /** ISO 8601 start of the observation window. */ + dataWindowStart: string; + /** ISO 8601 end of the observation window. */ + dataWindowEnd: string; + /** ISO 8601 timestamp the script ran. */ + extractedAt: string; + /** Script version (bumped on any logic change). */ + scriptVersion: string; +} + +export interface IndustryCut { + industry: string; + cohortSize: number; + aggregates: Record; +} + +/** Convenient type alias for a query function's return shape. */ +export type QueryResult = Record; + +/** Build an empty output skeleton, ready for query results to be merged in. */ +export function buildEmptyOutput(opts: { + datasetKey: string; + methodologyVersion: string; + dataWindowStart: Date; + dataWindowEnd: Date; + scriptVersion: string; + cohort: CohortSummary; +}): ExtractorOutput { + return { + metadata: { + datasetKey: opts.datasetKey, + methodologyVersion: opts.methodologyVersion, + dataWindowStart: opts.dataWindowStart.toISOString(), + dataWindowEnd: opts.dataWindowEnd.toISOString(), + extractedAt: new Date().toISOString(), + scriptVersion: opts.scriptVersion, + }, + cohort: opts.cohort, + aggregates: {}, + industries: {}, + }; +} diff --git a/apps/webapp/scripts/state-of-em-2026/probe.ts b/apps/webapp/scripts/state-of-em-2026/probe.ts new file mode 100644 index 0000000000..14606608b9 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/probe.ts @@ -0,0 +1,483 @@ +/** + * Feature-adoption probe for the State of Equipment Management 2026 report. + * + * This is the FIRST thing the data team runs. Before any query module is + * implemented and before any aggregate is published, the probe verifies + * that feature adoption across the eligible cohort is high enough that + * the v1.2 stat structure is defensible. + * + * Why this exists: + * + * v1.1 of the report leaned on ghost-asset-derived dollar headlines. The + * problem: Audits is a paid add-on (`Organization.auditsEnabled`). If, say, + * only 4% of Team workspaces have Audits enabled, framing "median ghost- + * asset value per workspace" as a platform stat is dishonest — it's + * actually "median ghost-asset value per audit-enabled minority". v1.2 + * pivoted the headline to idle-assets (universal `ActivityEvent` telemetry) + * and demoted ghost-asset stats to a qualified subset finding. + * + * The probe quantifies that risk explicitly. It produces a single JSON + * artifact the editorial team reads BEFORE we commit to the published stat + * structure. If the probe shows audits adoption below `auditsEnabledMin` + * (5%), the audit-subset stats are dropped from v1 entirely rather than + * published with a thin disclaimer. + * + * Probes are not reportable aggregates — they are diagnostic numbers about + * the cohort itself. They are not anonymized via `reportable()` because + * they do not appear in the published report. They land in + * `output/probe.json` for the data team's eyes only. + * + * @see ../methodology.md — risk-disclosure block in the published methodology + * @see ../README.md — workflow (probe → choose stats → implement queries) + * @see https://github.com/Shelf-nu/website-v2/blob/main/src/data/state-of-equipment-management-2026.ts + * — `adoptionThresholds` block on the website data file mirrors the + * thresholds used here. Keep them in sync. + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; + +import type { ExtractorContext } from "./context"; + +/** + * Thresholds for stat survival. These mirror the + * `reportMetadata.adoptionThresholds` block in + * `src/data/state-of-equipment-management-2026.ts` on the website-v2 PR. + * + * Update both sides in the same commit. + */ +export const ADOPTION_THRESHOLDS = { + /** Audits add-on adoption among eligible Team workspaces. Below this, audit-derived stats are scrapped. */ + auditsEnabledMin: 0.05, + /** Workspaces that actually ran an audit in window. Below this, ghost-asset and missing-rate stats are scrapped. */ + auditsRunMin: 0.03, + /** Workspaces with bookings activity. Below this, late-return stat is scrapped. */ + bookingsActiveMin: 0.1, + /** Asset.valuation coverage across the cohort. Below this, dollar headlines convert to percentages. */ + valuationCoverageMin: 0.3, +} as const; + +/** + * Per-stat decision returned by the probe. The data team consumes this + * directly: anything with `recommendation = "drop"` is excluded from v1. + */ +export type ProbeRecommendation = "publish" | "qualify" | "convert" | "drop"; + +/** + * One probe row per stat that depends on feature adoption. The probe + * surfaces the raw measurement (`adoptionRate`) and the resulting + * `recommendation` so editorial decisions are traceable. + */ +export interface ProbeRow { + statKey: string; + /** Plain-English description of what we measured. */ + measured: string; + /** Numerator from the measurement (e.g. count of audits-enabled orgs). */ + numerator: number; + /** Denominator (typically the eligible cohort size). */ + denominator: number; + /** numerator / denominator. Undefined when denominator is 0. */ + adoptionRate: number | null; + /** The threshold this rate is compared against. */ + threshold: number; + /** Decision for the editorial team. */ + recommendation: ProbeRecommendation; + /** Human-readable rationale that explains the recommendation. */ + rationale: string; +} + +/** + * Anonymous-scan capability check. The recovery stat (`ds_recovery_dollar + * _value_total`) requires that we can detect Found-via-Scan events from + * non-logged-in scanners. The Scan model uses `userId IS NULL` to indicate + * an anonymous scan (the schema does not have a dedicated boolean column); + * the probe confirms this signal is present and computes how many such + * events landed in the window. + */ +export interface AnonymousScanProbe { + /** Does the Scan model expose a way to identify anonymous scans? */ + detectionMethodAvailable: boolean; + /** Field/condition used. */ + detectionMethod: string; + /** Count of anonymous scans in the data window across all orgs. */ + countInWindow: number; + recommendation: ProbeRecommendation; + rationale: string; +} + +/** + * Top-level probe output. Written to `./output/probe.json` so the data team + * can review it before running the full extractor. The script's `--probe` + * mode skips the regular aggregate emission and writes only this file. + */ +export interface ProbeOutput { + /** ISO 8601 timestamp the probe ran at. */ + probedAt: string; + /** Data window the probe checked against. */ + dataWindowStart: string; + dataWindowEnd: string; + /** Eligible cohort size — every adoption rate is denominated against this. */ + cohortSize: number; + /** Thresholds the probe used (echoed for traceability). */ + thresholds: typeof ADOPTION_THRESHOLDS; + /** Per-stat adoption findings. */ + rows: ProbeRow[]; + /** Anonymous-scan capability check (drives the recovery stat decision). */ + anonymousScan: AnonymousScanProbe; + /** + * Stats the data team should drop from v1 based on the probe. Computed + * here so the human reviewer doesn't have to derive it. + */ + statsToDrop: string[]; + /** + * Stats that survived but should be published with explicit qualification + * (e.g. "Audit-enabled subset only — N% of cohort"). + */ + statsToQualify: string[]; +} + +/** + * Run the probe. Reads only — no writes. Returns the structured probe + * output for the orchestrator to serialize. + */ +export async function runProbe( + db: ExtendedPrismaClient, + ctx: ExtractorContext, +): Promise { + const cohortSize = ctx.eligibleOrgIds.length; + + // ----- Audits enabled (paid add-on) ----- + const auditsEnabledCount = await db.organization.count({ + where: { + id: { in: ctx.eligibleOrgIds }, + auditsEnabled: true, + }, + }); + const auditsEnabledRate = safeRate(auditsEnabledCount, cohortSize); + + // ----- Audits actually run (>= 1 COMPLETED AuditSession in window) ----- + const auditingOrgs = await db.auditSession.findMany({ + where: { + organizationId: { in: ctx.eligibleOrgIds }, + status: "COMPLETED", + startedAt: { + gte: ctx.dataWindowStart, + lte: ctx.dataWindowEnd, + }, + }, + distinct: ["organizationId"], + select: { organizationId: true }, + }); + const auditsRunCount = auditingOrgs.length; + const auditsRunRate = safeRate(auditsRunCount, cohortSize); + + // ----- Bookings activity in window ----- + const bookingOrgs = await db.booking.findMany({ + where: { + organizationId: { in: ctx.eligibleOrgIds }, + from: { + gte: ctx.dataWindowStart, + lte: ctx.dataWindowEnd, + }, + // Drafts are not real bookings; they would inflate this count. + status: { not: "DRAFT" }, + }, + distinct: ["organizationId"], + select: { organizationId: true }, + }); + const bookingsActiveCount = bookingOrgs.length; + const bookingsActiveRate = safeRate(bookingsActiveCount, cohortSize); + + // ----- Asset.valuation coverage (across the cohort) ----- + const assetsTotal = await db.asset.count({ + where: { + organizationId: { in: ctx.eligibleOrgIds }, + createdAt: { lte: ctx.dataWindowEnd }, + }, + }); + const assetsWithValuation = await db.asset.count({ + where: { + organizationId: { in: ctx.eligibleOrgIds }, + createdAt: { lte: ctx.dataWindowEnd }, + valuation: { not: null }, + }, + }); + const valuationCoverageRate = safeRate(assetsWithValuation, assetsTotal); + + // ----- Custody coverage (sanity check; threshold is editorial, not gating) ----- + const assetsWithCustody = await db.asset.count({ + where: { + organizationId: { in: ctx.eligibleOrgIds }, + createdAt: { lte: ctx.dataWindowEnd }, + custody: { isNot: null }, + }, + }); + const custodyCoverageRate = safeRate(assetsWithCustody, assetsTotal); + + // ----- Anonymous-scan capability check ----- + // The schema does not expose a `anonymous: Boolean` flag on Scan — the + // signal is `userId IS NULL`. The probe verifies the signal works by + // counting such scans in the window. A non-zero count confirms the + // capability; a zero count is a soft warning (could be no recovery + // events in window, or could be a wiring problem upstream). + const anonymousScanCount = await db.scan.count({ + where: { + userId: null, + createdAt: { + gte: ctx.dataWindowStart, + lte: ctx.dataWindowEnd, + }, + }, + }); + const anonymousScan: AnonymousScanProbe = { + detectionMethodAvailable: true, + detectionMethod: "Scan.userId IS NULL", + countInWindow: anonymousScanCount, + recommendation: + anonymousScanCount >= 20 ? "publish" : anonymousScanCount > 0 ? "qualify" : "drop", + rationale: + anonymousScanCount >= 20 + ? "Sufficient anonymous-scan volume to attribute Found-via-Scan recovery without identifying any single workspace." + : anonymousScanCount > 0 + ? `Only ${anonymousScanCount} anonymous scans in window — below k=20 floor. Publish only as percentage or omit dollar version.` + : "Zero anonymous scans detected in window. Either the data window has no recovery events or the detection method is mis-wired. Drop the recovery stat and investigate.", + }; + + // ----- Per-stat adoption decisions ----- + const rows: ProbeRow[] = [ + { + statKey: "ds_idle_asset_dollar_value_median_workspace", + measured: + "Asset.valuation coverage across cohort (denominator for dollar median; dollars are conservative lower bound)", + numerator: assetsWithValuation, + denominator: assetsTotal, + adoptionRate: valuationCoverageRate, + threshold: ADOPTION_THRESHOLDS.valuationCoverageMin, + recommendation: decideValuationDriven(valuationCoverageRate), + rationale: rationaleValuation(valuationCoverageRate), + }, + { + statKey: "ds_idle_asset_rate", + measured: + "Universal: idle rate via ActivityEvent. No feature dependency, only k-anonymity floor at query time.", + numerator: cohortSize, + denominator: cohortSize, + adoptionRate: cohortSize > 0 ? 1 : null, + threshold: 0, + recommendation: cohortSize >= ctx.minCohortSize ? "publish" : "drop", + rationale: + cohortSize >= ctx.minCohortSize + ? "Universal telemetry — no feature-adoption risk." + : "Eligible cohort itself is below k=20 floor; entire report is unreportable.", + }, + { + statKey: "pct_assets_with_active_custody", + measured: "Universal: Custody row presence across the cohort.", + numerator: assetsWithCustody, + denominator: assetsTotal, + adoptionRate: custodyCoverageRate, + threshold: 0, + recommendation: assetsTotal > 0 ? "publish" : "drop", + rationale: + assetsTotal > 0 + ? `Custody coverage measured at ${pct(custodyCoverageRate)}. Universal stat — no threshold gating.` + : "No assets in cohort; cannot compute.", + }, + { + statKey: "bk_pct_returned_late", + measured: "% of eligible orgs with at least one non-DRAFT Booking in window", + numerator: bookingsActiveCount, + denominator: cohortSize, + adoptionRate: bookingsActiveRate, + threshold: ADOPTION_THRESHOLDS.bookingsActiveMin, + recommendation: decideBookings(bookingsActiveRate), + rationale: rationaleBookings(bookingsActiveRate, bookingsActiveCount), + }, + { + statKey: "ds_recovery_dollar_value_total", + measured: "Anonymous Scans in window (detection via userId IS NULL)", + numerator: anonymousScanCount, + denominator: cohortSize, + adoptionRate: cohortSize > 0 ? anonymousScanCount / cohortSize : null, + threshold: 0.01, // soft check; recovery stat is a platform total not per-org + recommendation: anonymousScan.recommendation, + rationale: anonymousScan.rationale, + }, + { + statKey: "ds_ghost_asset_rate", + measured: "% of eligible orgs that ran >= 1 COMPLETED AuditSession in window", + numerator: auditsRunCount, + denominator: cohortSize, + adoptionRate: auditsRunRate, + threshold: ADOPTION_THRESHOLDS.auditsRunMin, + recommendation: decideAuditsRun(auditsRunRate), + rationale: rationaleAuditsRun(auditsRunRate, auditsRunCount), + }, + { + statKey: "au_pct_audited_assets_missing", + measured: + "Same sub-cohort as ghost rate: orgs that ran an audit in window. Reported as audit-enabled subset stat.", + numerator: auditsRunCount, + denominator: cohortSize, + adoptionRate: auditsRunRate, + threshold: ADOPTION_THRESHOLDS.auditsRunMin, + recommendation: decideAuditsRun(auditsRunRate), + rationale: rationaleAuditsRun(auditsRunRate, auditsRunCount), + }, + ]; + + // ----- Aggregate to-drop / to-qualify lists for the human reviewer ----- + const statsToDrop = rows + .filter((r) => r.recommendation === "drop") + .map((r) => r.statKey); + const statsToQualify = rows + .filter((r) => r.recommendation === "qualify" || r.recommendation === "convert") + .map((r) => r.statKey); + + // ----- Adoption-level audits header (used by orchestrator to log) ----- + // The probe also surfaces a top-level audits-enabled rate for editorial + // context — even if no stat directly gates on it, the rate informs how + // the audit-subset narrative is written. + rows.unshift({ + statKey: "_meta__audits_enabled", + measured: + "% of eligible orgs with Audits add-on enabled (paid feature). Editorial context only — does not directly gate a stat.", + numerator: auditsEnabledCount, + denominator: cohortSize, + adoptionRate: auditsEnabledRate, + threshold: ADOPTION_THRESHOLDS.auditsEnabledMin, + recommendation: auditsEnabledRate !== null && auditsEnabledRate >= ADOPTION_THRESHOLDS.auditsEnabledMin ? "publish" : "qualify", + rationale: + auditsEnabledRate !== null && auditsEnabledRate >= ADOPTION_THRESHOLDS.auditsEnabledMin + ? `Audits add-on enabled in ${pct(auditsEnabledRate)} of cohort — large enough to publish a qualified audit-subset section.` + : `Audits adoption is ${pct(auditsEnabledRate)}, below the ${pct(ADOPTION_THRESHOLDS.auditsEnabledMin)} threshold. Consider cutting the entire audit-subset section.`, + }); + + return { + probedAt: new Date().toISOString(), + dataWindowStart: ctx.dataWindowStart.toISOString(), + dataWindowEnd: ctx.dataWindowEnd.toISOString(), + cohortSize, + thresholds: ADOPTION_THRESHOLDS, + rows, + anonymousScan, + statsToDrop, + statsToQualify, + }; +} + +/** + * Pretty-print the probe to stdout. Used by the orchestrator when running + * in `--probe` mode so the data team can read the result at the terminal + * without opening the JSON file. + */ +export function printProbeSummary(probe: ProbeOutput): void { + console.log( + "\n=== Feature-adoption probe ===\n" + + `Cohort size: ${probe.cohortSize}\n` + + `Data window: ${probe.dataWindowStart.slice(0, 10)} → ${probe.dataWindowEnd.slice(0, 10)}\n` + + `Probed at: ${probe.probedAt}\n`, + ); + + for (const row of probe.rows) { + const rate = row.adoptionRate === null ? "n/a" : pct(row.adoptionRate); + const flag = recommendationFlag(row.recommendation); + console.log( + `${flag} ${row.statKey}\n` + + ` measured: ${row.measured}\n` + + ` adoption: ${rate} (threshold: ${pct(row.threshold)}) → ${row.recommendation.toUpperCase()}\n` + + ` ${row.rationale}\n`, + ); + } + + console.log( + "Anonymous-scan capability check\n" + + ` detection: ${probe.anonymousScan.detectionMethod}\n` + + ` in-window: ${probe.anonymousScan.countInWindow}\n` + + ` decision: ${probe.anonymousScan.recommendation.toUpperCase()}\n` + + ` ${probe.anonymousScan.rationale}\n`, + ); + + if (probe.statsToDrop.length > 0) { + console.log( + `\nSTATS TO DROP FROM v1 (below threshold):\n` + + probe.statsToDrop.map((s) => ` - ${s}`).join("\n"), + ); + } + if (probe.statsToQualify.length > 0) { + console.log( + `\nSTATS TO PUBLISH WITH QUALIFICATION:\n` + + probe.statsToQualify.map((s) => ` - ${s}`).join("\n"), + ); + } + if (probe.statsToDrop.length === 0 && probe.statsToQualify.length === 0) { + console.log("\nAll v1.2 stats clear the adoption thresholds. Proceed to query implementation.\n"); + } +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function safeRate(num: number, den: number): number | null { + return den > 0 ? num / den : null; +} + +function pct(rate: number | null): string { + if (rate === null) return "n/a"; + return `${(rate * 100).toFixed(1)}%`; +} + +function decideAuditsRun(rate: number | null): ProbeRecommendation { + if (rate === null) return "drop"; + if (rate >= ADOPTION_THRESHOLDS.auditsRunMin) return "qualify"; + return "drop"; +} + +function rationaleAuditsRun(rate: number | null, count: number): string { + if (rate === null) return "Cannot compute audits-run rate (cohort size zero)."; + if (rate >= ADOPTION_THRESHOLDS.auditsRunMin) { + return `${count} orgs (${pct(rate)} of cohort) ran an audit in window. Publish as "audit-enabled subset" finding with explicit qualification; never as platform median.`; + } + return `Only ${count} orgs (${pct(rate)} of cohort) ran an audit in window — below the ${pct(ADOPTION_THRESHOLDS.auditsRunMin)} threshold. Drop audit-derived stats from v1 to avoid the "median of an audit-enabled minority" framing risk.`; +} + +function decideBookings(rate: number | null): ProbeRecommendation { + if (rate === null) return "drop"; + if (rate >= ADOPTION_THRESHOLDS.bookingsActiveMin) return "qualify"; + return "drop"; +} + +function rationaleBookings(rate: number | null, count: number): string { + if (rate === null) return "Cannot compute bookings-active rate (cohort size zero)."; + if (rate >= ADOPTION_THRESHOLDS.bookingsActiveMin) { + return `${count} orgs (${pct(rate)} of cohort) have bookings activity in window. Publish bk_pct_returned_late with the standing "among workspaces using bookings" qualifier.`; + } + return `Only ${count} orgs (${pct(rate)} of cohort) use bookings — below the ${pct(ADOPTION_THRESHOLDS.bookingsActiveMin)} threshold. Drop the late-return stat from v1.`; +} + +function decideValuationDriven(rate: number | null): ProbeRecommendation { + if (rate === null) return "drop"; + if (rate >= ADOPTION_THRESHOLDS.valuationCoverageMin) return "publish"; + return "convert"; +} + +function rationaleValuation(rate: number | null): string { + if (rate === null) return "Cannot compute valuation coverage."; + if (rate >= ADOPTION_THRESHOLDS.valuationCoverageMin) { + return `Asset.valuation coverage is ${pct(rate)} — clears the ${pct(ADOPTION_THRESHOLDS.valuationCoverageMin)} threshold. Publish dollar headline as conservative lower bound; disclose coverage in methodology.`; + } + return `Asset.valuation coverage is only ${pct(rate)} — below the ${pct(ADOPTION_THRESHOLDS.valuationCoverageMin)} threshold. CONVERT the dollar headline to a percentage headline (idle rate) and drop the dollar figure to avoid an unrepresentative number.`; +} + +function recommendationFlag(rec: ProbeRecommendation): string { + switch (rec) { + case "publish": + return "[OK ]"; + case "qualify": + return "[QUAL ]"; + case "convert": + return "[CONV ]"; + case "drop": + return "[DROP ]"; + } +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/audits.ts b/apps/webapp/scripts/state-of-em-2026/queries/audits.ts new file mode 100644 index 0000000000..270e31fab1 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/audits.ts @@ -0,0 +1,73 @@ +/** + * Audits queries — trimmed to the two stats in the v1 published headline + * structure. + * + * Produces: + * au_pct_workspaces_running_audits — % of audits-enabled Orgs with >= 1 + * AuditSession reaching COMPLETED + * status in the window + * au_pct_audited_assets_missing — % of expected AuditAsset rows that + * came up Missing on first scan + * + * The original v0 audit stubs (au_pct_audited_assets_found, au_pct_audited + * _assets_unexpected, au_median_completion_days, median_audit_completion_ + * days) were cut from the published report. They remain in git history + * for restoration in 2027 if useful. + * + * Cohort sub-filter: Organization.auditsEnabled = true AND in eligibleOrgIds. + * Apply k-anonymity to this sub-cohort separately — don't rely on the + * global eligible cohort floor. + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { QueryResult } from "../output-schema"; + +export async function runAuditsQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise { + // TODO: implement. + // + // Step 1: build the audits-enabled sub-cohort. + // const auditEnabledOrgIds = await db.organization.findMany({ + // where: { id: { in: ctx.eligibleOrgIds }, auditsEnabled: true }, + // select: { id: true }, + // }).then(rows => rows.map(r => r.id)); + // + // Step 2: workspaces running audits. + // const auditingOrgIds = await db.auditSession.findMany({ + // where: { + // organizationId: { in: auditEnabledOrgIds }, + // status: "COMPLETED", + // startedAt: { gte: ctx.dataWindowStart, lte: ctx.dataWindowEnd }, + // }, + // distinct: ['organizationId'], + // select: { organizationId: true }, + // }).then(rows => new Set(rows.map(r => r.organizationId))); + // value = (auditingOrgIds.size / auditEnabledOrgIds.length) * 100 + // cohortSize = auditEnabledOrgIds.length + // + // Step 3: missing rate. + // const sums = await db.auditSession.aggregate({ + // where: { ... within window, COMPLETED, in audits-enabled orgs ... }, + // _sum: { expectedAssetCount: true, missingAssetCount: true }, + // }); + // value = (missing / expected) * 100 + // cohortSize = number of audit sessions OR number of contributing orgs; + // pick the more conservative (orgs). + + return { + au_pct_workspaces_running_audits: notImplementedAggregate({ + key: "au_pct_workspaces_running_audits", + label: "Of Team-tier workspaces with the Audits add-on ran at least one audit in the year", + unit: "%", + }), + au_pct_audited_assets_missing: notImplementedAggregate({ + key: "au_pct_audited_assets_missing", + label: "Of expected assets came up Missing on the first audit scan", + unit: "%", + }), + }; +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/bookings.ts b/apps/webapp/scripts/state-of-em-2026/queries/bookings.ts new file mode 100644 index 0000000000..4ba2ed5d73 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/bookings.ts @@ -0,0 +1,90 @@ +/** + * Bookings queries — un-deferred in v1.2 for one stat: `bk_pct_returned_late`. + * + * v1.0 of this file emitted seven booking stats. v1.1 deferred the entire + * file as out-of-scope for the trimmed-to-eight headline structure. v1.2 + * brings back the late-return rate because it pairs with the new idle-asset + * headline: idle assets are "dead capital" and late returns are "cascade + * friction" — they're the two faces of the same operational problem in the + * report. + * + * Produces: + * bk_pct_returned_late — % of bookings whose return time exceeded `to` + * among workspaces that used bookings in window + * + * Cohort sub-filter: restrict to Organizations that have at least one + * non-DRAFT Booking with `from` inside the data window. The probe at + * ../probe.ts measures this rate before queries run; if it drops below the + * `bookingsActiveMin` threshold (10%), the website MDX drops the late- + * return section entirely. + * + * The six other stats from v1.0 (avg bookings per month, conflict averted, + * lead time, overdue hours, peak day) remain deferred. Their stubs live in + * git history; restore in 2027 if the editorial team wants them back. + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { QueryResult } from "../output-schema"; + +export async function runBookingsQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise { + // TODO: implement. + // + // --------------------------------------------------------------- + // bk_pct_returned_late + // --------------------------------------------------------------- + // Definition (matches website MDX): + // A booking is "returned late" if either: + // (a) status IN ('COMPLETE', 'ARCHIVED') AND the BOOKING_CHECKED_IN + // ActivityEvent for the booking has occurredAt > Booking.to, OR + // (b) status IN ('ONGOING', 'OVERDUE') AND NOW() > Booking.to (still + // out past the scheduled return) + // + // The Booking model itself does not carry an `actualReturnAt` column, so + // determining the actual check-in timestamp requires reading the + // ActivityEvent log. The most efficient approach is a single raw SQL + // query joining Booking → ActivityEvent on bookingId and action = + // 'BOOKING_CHECKED_IN', taking the MAX(occurredAt) per booking. + // + // WITH window_bookings AS ( + // SELECT id, "organizationId", status, "to" + // FROM "Booking" + // WHERE "organizationId" = ANY($eligibleOrgIds) + // AND "from" >= $dataWindowStart + // AND "from" <= $dataWindowEnd + // AND status NOT IN ('DRAFT', 'CANCELLED') + // ), + // checked_in AS ( + // SELECT "bookingId", MAX("occurredAt") AS checked_in_at + // FROM "ActivityEvent" + // WHERE "bookingId" IS NOT NULL + // AND action = 'BOOKING_CHECKED_IN' + // GROUP BY "bookingId" + // ) + // SELECT COUNT(*) FILTER (WHERE + // (wb.status IN ('COMPLETE', 'ARCHIVED') AND ci.checked_in_at > wb."to") + // OR (wb.status IN ('ONGOING', 'OVERDUE') AND NOW() > wb."to") + // ) AS late_count, + // COUNT(*) AS total_count + // FROM window_bookings wb + // LEFT JOIN checked_in ci ON ci."bookingId" = wb.id; + // + // value = late_count / total_count * 100 + // cohortSize = distinct organization count contributing to window_bookings + // (apply k-anonymity to this sub-cohort, not the global one) + // + // Wrap the result with reportable({ ... }) — do NOT build a + // ReportableAggregate directly. + + return { + bk_pct_returned_late: notImplementedAggregate({ + key: "bk_pct_returned_late", + label: "Of bookings ended after the scheduled return time (booking-using subset)", + unit: "%", + }), + }; +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/custody.ts b/apps/webapp/scripts/state-of-em-2026/queries/custody.ts new file mode 100644 index 0000000000..63054400b5 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/custody.ts @@ -0,0 +1,58 @@ +/** + * Custody queries — "Custody and accountability" section. + * + * Produces (keys match website-v2 sectionStats.custody): + * + * cu_pct_assets_with_history — % of Assets with >= 1 custody ActivityEvent in window + * cu_median_handovers_per_asset_per_year — median count of custody transfer events per + * active asset over the window + * cu_top_handover_categories — top categories by mean handover count per asset + * + * Custody is split between two sources: + * - `Custody` table = current state (one row per asset that currently has custody) + * - `ActivityEvent` rows where action indicates custody transfer = history + * + * Per the discovery report: don't reconstruct history from Custody.updatedAt; + * use ActivityEvent rows with custody-related action enum values. + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { QueryResult } from "../output-schema"; + +export async function runCustodyQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise { + // TODO: implement. + // + // Implementation guidance: + // - Confirm the exact ActivityAction enum values that represent custody + // transfer (likely CUSTODY_ASSIGN, CUSTODY_RELEASE, CUSTODY_TRANSFER + // or similar). Inspect packages/database/prisma/schema.prisma for the + // ActivityAction enum. + // - Active asset = Asset with at least one ActivityEvent in the window + // (any action, not just custody-related). + // - Handover count per asset = count of ActivityEvent rows of the + // custody-related action enum values, grouped by assetId, within window. + // - Top handover categories: groupBy assetId.categoryId then average the + // handover counts; restrict to categories with >= --min-cohort-size + // distinct workspaces. + + return { + cu_pct_assets_with_history: notImplementedAggregate({ + key: "cu_pct_assets_with_history", + label: "Of assets have one or more custody events in the last year", + unit: "%", + }), + cu_median_handovers_per_asset_per_year: notImplementedAggregate({ + key: "cu_median_handovers_per_asset_per_year", + label: "Median custody handovers per asset per year (active assets only)", + }), + cu_top_handover_categories: notImplementedAggregate({ + key: "cu_top_handover_categories", + label: "Asset categories with the highest handover rate", + }), + }; +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/disorder.ts b/apps/webapp/scripts/state-of-em-2026/queries/disorder.ts new file mode 100644 index 0000000000..368ee80301 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/disorder.ts @@ -0,0 +1,169 @@ +/** + * Cost-of-disorder queries — produces the v1.2 viral headline (IDLE-asset + * dollar value, universal telemetry), the idle rate companion, the Found- + * via-Scan recovery total, and the now-demoted audit-subset ghost-asset + * rate. + * + * v1.2 pivot summary: + * + * PRIMARY (universal, no feature dependency): + * ds_idle_asset_dollar_value_median_workspace — THE HEADLINE. + * Median workspace's $ value + * of assets idle for 90+ days. + * ds_idle_asset_rate — % of tracked assets idle. + * + * SUPPORTING (universal IF Scan model exposes anonymous detection): + * ds_recovery_dollar_value_total — total $ recovered via + * Found-via-Scan in window. + * + * AUDIT-ENABLED SUBSET (qualified — published only with the qualifier + * "audit-enabled subset only" in the website MDX): + * ds_ghost_asset_rate — % of audited assets that + * meet the ghost definition. + * + * Definitions in ../methodology.md. The probe in ../probe.ts decides which of + * these survive to publication; this module computes them whether the probe + * recommended dropping them or not. The website MDX is the gate. + * + * Asset.valuation coverage caveat: not every Asset row carries a valuation + * (the field is workspace-entered). The dollar aggregates compute over the + * assets that DO have a valuation, then median-extrapolate per workspace. + * If the probe reports valuation coverage below 30%, the dollar headline + * should be replaced by the percentage headline (`ds_idle_asset_rate`). + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { QueryResult } from "../output-schema"; + +export async function runDisorderQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise { + // TODO: implement. + // + // --------------------------------------------------------------- + // PRIMARY: ds_idle_asset_dollar_value_median_workspace (THE HEADLINE) + // --------------------------------------------------------------- + // Idle = an Asset with no ActivityEvent (any action) in the prior 90 + // days at end of window, AND created before the 90-day idle window + // opened (new assets without history are not idle). + // + // Implementation sketch (raw SQL recommended for the LEFT JOIN — Prisma's + // ORM is awkward at "no related row in the last N days"): + // + // WITH idle_cutoff AS (SELECT $dataWindowEnd::timestamptz - INTERVAL '90 days' AS t), + // eligible_assets AS ( + // SELECT a.id, a."organizationId", a.value + // FROM "Asset" a, idle_cutoff + // WHERE a."organizationId" = ANY($eligibleOrgIds) + // AND a."createdAt" <= idle_cutoff.t + // ), + // recent_activity AS ( + // SELECT DISTINCT ae."assetId" + // FROM "ActivityEvent" ae, idle_cutoff + // WHERE ae."assetId" IS NOT NULL + // AND ae."occurredAt" > idle_cutoff.t + // AND ae."occurredAt" <= $dataWindowEnd + // ), + // idle_assets AS ( + // SELECT ea.* + // FROM eligible_assets ea + // LEFT JOIN recent_activity ra ON ra."assetId" = ea.id + // WHERE ra."assetId" IS NULL + // ), + // per_workspace AS ( + // SELECT "organizationId", COALESCE(SUM(value), 0) AS idle_dollar_sum + // FROM idle_assets + // WHERE value IS NOT NULL + // GROUP BY "organizationId" + // ) + // SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY idle_dollar_sum) AS median + // FROM per_workspace; + // + // NOTE: ActivityEvent only stores actions Shelf has chosen to record. If + // a workspace uses Scan-only activity that does not produce an + // ActivityEvent, asset would be flagged as idle when it is not. Sanity- + // check: also LEFT JOIN against "Scan"."createdAt" > cutoff via + // qrId → Qr.assetId. The methodology must disclose the signal sources. + // + // cohortSize = number of workspaces that contributed to the per_workspace + // CTE (i.e. orgs with >= 1 valued idle asset). Apply k-anonymity to that + // sub-cohort, not the global cohort. + // + // --------------------------------------------------------------- + // ds_idle_asset_rate + // --------------------------------------------------------------- + // numerator = COUNT(idle_assets) — including those with no valuation + // denominator = COUNT(eligible_assets) + // value = numerator / denominator * 100 + // cohortSize = ctx.eligibleOrgIds.length (universal stat) + // + // --------------------------------------------------------------- + // ds_recovery_dollar_value_total (DEPENDS on anonymous-scan capability) + // --------------------------------------------------------------- + // Recovery = Scan event with userId IS NULL whose associated asset (via + // Qr → Asset) was previously marked Missing OR Idle in the + // window. + // + // SELECT COALESCE(SUM(a.value), 0) + // FROM "Scan" s + // JOIN "Qr" q ON q.id = s."qrId" + // JOIN "Asset" a ON a.id = q."assetId" + // WHERE s."userId" IS NULL + // AND s."createdAt" BETWEEN $dataWindowStart AND $dataWindowEnd + // AND a."organizationId" = ANY($eligibleOrgIds) + // AND a.value IS NOT NULL + // AND EXISTS ( ... prior Missing OR Idle marker for this asset ... ); + // + // Cohort: this is a platform-wide total, not per-workspace. K-anonymity + // floor still applies to the count of recovery events behind the total + // (>= 20 distinct recovery events; otherwise cohort_too_small). + // + // The probe in ../probe.ts pre-checks whether anonymous scans exist in + // the window. If the probe returned `drop`, this query should bail to + // not_implemented and the website MDX drops the recovery section. + // + // --------------------------------------------------------------- + // AUDIT-ENABLED SUBSET: ds_ghost_asset_rate (qualified) + // --------------------------------------------------------------- + // Ghost = Asset where: + // - exists in inventory + // - was on the expected list of >= 2 consecutive AuditAsset rows + // with status = MISSING + // - has had no AuditScan or Scan event between those audits anywhere + // on the platform + // + // This is the v1.1 query, now scoped to the audit-enabled sub-cohort + // and published only with explicit qualification ("audit-enabled + // subset only — N% of cohort"). Implementation likely cleaner as raw + // SQL than Prisma ORM. Use db.$queryRaw with parameterized inputs. + // + // CRITICAL: every aggregate goes through reportable({ ... }) for + // k-anonymity + sig-fig rounding. Direct ReportableAggregate + // construction bypasses safety; flag in code review. + + return { + ds_idle_asset_dollar_value_median_workspace: notImplementedAggregate({ + key: "ds_idle_asset_dollar_value_median_workspace", + label: "Median workspace's dollar value of equipment idle for 90+ days (THE HEADLINE)", + unit: " USD", + }), + ds_idle_asset_rate: notImplementedAggregate({ + key: "ds_idle_asset_rate", + label: "Of tracked assets had no activity in the prior 90 days at end of window", + unit: "%", + }), + ds_recovery_dollar_value_total: notImplementedAggregate({ + key: "ds_recovery_dollar_value_total", + label: "Total dollar value of equipment recovered via Found-via-Scan in window", + unit: " USD", + }), + ds_ghost_asset_rate: notImplementedAggregate({ + key: "ds_ghost_asset_rate", + label: "Of audited assets are ghost assets (audit-enabled subset only)", + unit: "%", + }), + }; +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/index.ts b/apps/webapp/scripts/state-of-em-2026/queries/index.ts new file mode 100644 index 0000000000..0639952d3f --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/index.ts @@ -0,0 +1,14 @@ +/** + * Barrel re-export for the queries directory. + * + * Keeps the orchestrator's import block tidy and gives the data team a + * single file to add new query modules to as the report evolves. + */ + +export { runVisibilityQueries } from "./visibility"; +export { runBookingsQueries } from "./bookings"; +export { runCustodyQueries } from "./custody"; +export { runAuditsQueries } from "./audits"; +export { runDisorderQueries } from "./disorder"; +export { runIndustryQueries } from "./industries"; +export { runTopPerformerQueries } from "./top-performers"; diff --git a/apps/webapp/scripts/state-of-em-2026/queries/industries.ts b/apps/webapp/scripts/state-of-em-2026/queries/industries.ts new file mode 100644 index 0000000000..0cc8c401f7 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/industries.ts @@ -0,0 +1,123 @@ +/** + * Industry cuts — segments the eligible cohort by industry and runs + * representative queries per segment. + * + * Industries reported (matching website-v2 industryCuts): + * - Education + * - IT & Technology + * - Media & Production + * - Construction & Field Operations + * + * Industry assignment: best-effort via `UserBusinessIntel.primaryUseCase` + * and `UserBusinessIntel.industry` on the workspace owner's record (per the + * discovery report). Workspaces without business intel are bucketed as + * "Unspecified" and excluded from per-industry stats. + * + * @see ../methodology.md — industry assignment + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { IndustryCut } from "../output-schema"; + +const INDUSTRIES = [ + "Education", + "IT & Technology", + "Media & Production", + "Construction & Field Operations", +] as const; + +export async function runIndustryQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise> { + // TODO: implement. + // + // Implementation guidance: + // - Step 1: for each industry, resolve the workspace subset via + // UserBusinessIntel joined to Organization via the owner. + // - Step 2: apply the k-anonymity floor per industry. If a sub-cohort + // is too small, return cohortSize but null aggregates for that industry. + // - Step 3: run the industry-specific queries: + // Education: median assets, median users, peak booking month + // IT: % laptops/computing, median custody duration + // Media: % kits in bookings, % camera/lens/audio assets + // Construction: % multi-location workspaces, % tool assets + // - Some stats are cross-industry (e.g. % laptops can be defined for any + // industry); reusing the visibility-query helpers and scoping to the + // industry's orgIds is the cleanest implementation. + + const result: Record = {}; + + for (const industry of INDUSTRIES) { + result[industry] = { + industry, + cohortSize: 0, // TODO: real per-industry workspace count + aggregates: buildIndustryStubAggregates(industry), + }; + } + + return result; +} + +function buildIndustryStubAggregates(industry: string): Record> { + switch (industry) { + case "Education": + return { + ed_median_assets: notImplementedAggregate({ + key: "ed_median_assets", + label: "Median assets per workspace", + }), + ed_median_users: notImplementedAggregate({ + key: "ed_median_users", + label: "Median active users per workspace", + }), + ed_seasonal_peak_month: notImplementedAggregate({ + key: "ed_seasonal_peak_month", + label: "Peak booking month", + }), + }; + case "IT & Technology": + return { + it_pct_laptops: notImplementedAggregate({ + key: "it_pct_laptops", + label: "Of tracked assets are laptops or computing devices", + unit: "%", + }), + it_median_custody_duration_days: notImplementedAggregate({ + key: "it_median_custody_duration_days", + label: "Median custody duration", + unit: " days", + }), + }; + case "Media & Production": + return { + mp_pct_kits: notImplementedAggregate({ + key: "mp_pct_kits", + label: "Of bookings include at least one kit", + unit: "%", + }), + mp_pct_camera_lens_audio: notImplementedAggregate({ + key: "mp_pct_camera_lens_audio", + label: "Of assets are camera, lens, or audio equipment", + unit: "%", + }), + }; + case "Construction & Field Operations": + return { + co_pct_multi_location: notImplementedAggregate({ + key: "co_pct_multi_location", + label: "Of workspaces operate across two or more locations", + unit: "%", + }), + co_pct_tool_assets: notImplementedAggregate({ + key: "co_pct_tool_assets", + label: "Of assets are categorized as tools", + unit: "%", + }), + }; + default: + return {}; + } +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/top-performers.ts b/apps/webapp/scripts/state-of-em-2026/queries/top-performers.ts new file mode 100644 index 0000000000..a6d9c60455 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/top-performers.ts @@ -0,0 +1,74 @@ +/** + * Top-performer patterns — "What top performers do differently" section. + * + * Segments the eligible cohort into top-quartile workspaces and identifies + * the behavioral patterns that distinguish them from median peers. + * + * Top-quartile definition: workspaces whose Missing rate is in the bottom + * quartile AND on-time return rate is in the top quartile (i.e. the + * workspaces that lose the fewest assets and return the most bookings on + * time). Apply k-anonymity to the top-quartile sub-cohort. + * + * Produces (keys match website-v2 topPerformerPatterns): + * early_custody_assignment — quantified delta in missing rate + * quarterly_audit_cadence — quantified delta in ghost-asset rate + * qr_labels_at_intake — quantified delta in custody coverage + * kit_grouping — quantified delta in missing-accessory rate + * + * These are CORRELATIONS not causal claims. The report copy is careful + * about this; the query output should be too. + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { QueryResult } from "../output-schema"; + +export async function runTopPerformerQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise { + // TODO: implement. + // + // Implementation guidance: + // - First, score each workspace on missing-rate and on-time-return-rate. + // Take the intersection of bottom-quartile-missing AND top-quartile- + // on-time; that's the top performer cohort. + // - For each pattern, compute the median value of the relevant metric + // within the top cohort vs the rest of the eligible cohort. + // Report the delta (e.g. "X percentage points lower missing rate"). + // - Patterns to measure: + // 1. Early-custody-assignment: time from Asset.createdAt to first + // custody-related ActivityEvent. Top performers vs rest, median. + // 2. Quarterly audit cadence: count of completed audits per year + // per workspace. Top performers vs rest, median. + // 3. QR labels at intake: % of Assets that have a QR association + // record within 7 days of Asset.createdAt. Top vs rest. + // 4. Kit grouping: median ratio of Kit count to component-asset + // count for kit-using workspaces. Top vs rest. + // - Each metric needs its own k-anonymity check because the top- + // quartile sub-cohort may shrink the eligible N significantly. + + return { + tp_early_custody_assignment_delta: notImplementedAggregate({ + key: "tp_early_custody_assignment_delta", + label: "Top performers: median delta in missing rate from assigning custody within 48h", + unit: " percentage points", + }), + tp_quarterly_audit_cadence_delta: notImplementedAggregate({ + key: "tp_quarterly_audit_cadence_delta", + label: "Top performers: median delta in ghost-asset rate from quarterly audit cadence", + unit: " percentage points", + }), + tp_qr_labels_at_intake_delta: notImplementedAggregate({ + key: "tp_qr_labels_at_intake_delta", + label: "Top performers: median delta in custody coverage from labeling at intake", + unit: " percentage points", + }), + tp_kit_grouping_delta: notImplementedAggregate({ + key: "tp_kit_grouping_delta", + label: "Top performers: median delta in missing-accessory rate from kit grouping", + unit: " percentage points", + }), + }; +} diff --git a/apps/webapp/scripts/state-of-em-2026/queries/visibility.ts b/apps/webapp/scripts/state-of-em-2026/queries/visibility.ts new file mode 100644 index 0000000000..308f82aca9 --- /dev/null +++ b/apps/webapp/scripts/state-of-em-2026/queries/visibility.ts @@ -0,0 +1,48 @@ +/** + * Visibility queries — trimmed to the one stat that appears in the v1 + * published headline structure: pct_assets_with_active_custody. + * + * The original v0 stubs (median_assets_per_workspace, median_users_per_ + * workspace, vis_assets_with_location, vis_assets_with_category, etc.) + * were cut from the published report per editorial review — they are + * demographic noise that nobody outside Shelf would quote. They remain + * in the git history for restoration in 2027 if useful. + * + * @see ../README.md — the trimmed-scope explanation + */ + +import type { ExtendedPrismaClient } from "@shelf/database"; +import { notImplementedAggregate } from "../anonymize"; +import type { ExtractorContext } from "../context"; +import type { QueryResult } from "../output-schema"; + +export async function runVisibilityQueries( + _db: ExtendedPrismaClient, + _ctx: ExtractorContext, +): Promise { + // TODO: implement against the eligible cohort in _ctx.eligibleOrgIds. + // + // pct_assets_with_active_custody: + // numerator = Asset.count({ where: { + // Custody: { isNot: null }, + // organizationId: { in: ids }, + // createdAt: { lte: dataWindowEnd }, + // }}) + // denominator = Asset.count({ where: { + // organizationId: { in: ids }, + // createdAt: { lte: dataWindowEnd }, + // }}) + // value = (numerator / denominator) * 100 + // cohortSize = ctx.eligibleOrgIds.length + // + // Wrap the result with reportable({ ... }) — do NOT build a + // ReportableAggregate directly. + + return { + pct_assets_with_active_custody: notImplementedAggregate({ + key: "pct_assets_with_active_custody", + label: "Of assets have an active custodian assigned", + unit: "%", + }), + }; +} diff --git a/package.json b/package.json index d020d13d66..0238af3264 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "webapp:seed:reporting-demo:staging": "pnpm --filter @shelf/webapp seed:reporting-demo:staging", "webapp:clean:reporting-demo": "pnpm --filter @shelf/webapp clean:reporting-demo", "webapp:clean:reporting-demo:staging": "pnpm --filter @shelf/webapp clean:reporting-demo:staging", + "webapp:report:state-of-em-2026": "pnpm --filter @shelf/webapp report:state-of-em-2026", + "webapp:report:state-of-em-2026:staging": "pnpm --filter @shelf/webapp report:state-of-em-2026:staging", "companion:dev": "pnpm --filter @shelf/companion dev", "companion:dev:clear": "pnpm --filter @shelf/companion dev:clear", "companion:dev:tunnel": "pnpm --filter @shelf/companion dev:tunnel",