Files
lefarcen fab172b782 feat(analytics): emit file_upload_result from all three upload entries (#2459)
* feat(analytics): emit file_upload_result from all three upload entries

`file_upload_result` was wired only on the Design Files Upload button in
FileWorkspace. The chat composer paperclip (project page) and the home
hero composer paperclip uploaded files silently — PostHog dashboards
saw upload activity from one of three real entry points, so per-surface
funnels were invisible and totals undercounted.

Three problems are fixed together:

1. `FileUploadResultProps` hard-coded `page_name: 'file_manager' /
   area: 'file_manager'`, which prevented the other two surfaces from
   type-checking. Widened to a discriminated union over the three v2
   doc surfaces (`file_manager` / `chat_panel` chat_composer /
   `home` chat_composer).

2. `HomeChatComposerClickProps.element` was missing `'attachment'`, so
   the home composer paperclip had no usable click value even if we
   wanted to instrument it. Added the literal, mirroring the
   chat_panel composer.

3. Three call sites for `file_upload_result` would duplicate the
   per-file mime + total-bytes cohort math. Extracted to
   `apps/web/src/analytics/upload-tracking.ts#deriveUploadCohort` so
   FileWorkspace, ChatComposer, and the App.tsx Home submit path all
   compute the same `file_count` / `file_type` / `file_size_bucket`
   triplet. FileWorkspace's inline math is replaced with the shared
   helper to prevent drift.

Call-site wiring:

- HomeHero attach button: `ui_click` (`element='attachment'`) at
  click time. The actual upload is deferred to submit, so the
  `file_upload_result` for this surface fires from App.tsx after
  `uploadProjectFiles` resolves.
- ChatComposer.uploadFiles: `file_upload_result` on success / failed /
  throw branches; existing `ui_click` (`element='attachment'`) at the
  paperclip stays as-is.
- FileWorkspace.uploadFiles: refactored to use `deriveUploadCohort`;
  behavior unchanged.

* test(analytics): cover deriveUploadCohort matrix

Reviewer flagged that deriveUploadCohort silently fans out to three
upload entry points (file_manager / chat_panel / home) but has no
focused coverage, so a regression in zip detection, mixed-type
collapsing, or the 1/10/100 MB thresholds would skew analytics
without breaking any visible UI behavior.

Adds homogeneous-image, zip-by-mime, zip-by-extension, mixed-type,
empty-batch, bucket-boundary (1/10/100 MB), and defensive
empty-mime cases.
2026-05-21 18:28:56 +08:00

95 lines
2.9 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { deriveUploadCohort } from '../../src/analytics/upload-tracking';
// `deriveUploadCohort` only reads `name`, `type`, `size` — keep the
// fixture lightweight so 100 MB boundary cases don't allocate real bytes.
function makeFile(name: string, type: string, size: number): File {
return { name, type, size } as unknown as File;
}
describe('deriveUploadCohort', () => {
it('classifies a homogeneous image batch as image', () => {
const cohort = deriveUploadCohort([
makeFile('a.png', 'image/png', 1024),
makeFile('b.jpg', 'image/jpeg', 2048),
]);
expect(cohort).toEqual({
file_count: 2,
file_type: 'image',
file_size_bucket: '0_1mb',
});
});
it('detects zip by MIME', () => {
const cohort = deriveUploadCohort([
makeFile('bundle.zip', 'application/zip', 500),
]);
expect(cohort.file_type).toBe('zip');
});
it('detects zip by .zip extension when MIME is missing', () => {
const cohort = deriveUploadCohort([
makeFile('bundle.ZIP', '', 500),
makeFile('also.zip', 'application/octet-stream', 500),
]);
expect(cohort.file_type).toBe('zip');
});
it('collapses mixed-type batches to other', () => {
const cohort = deriveUploadCohort([
makeFile('a.png', 'image/png', 100),
makeFile('b.pdf', 'application/pdf', 100),
]);
expect(cohort.file_type).toBe('other');
});
it('returns other for an empty batch', () => {
expect(deriveUploadCohort([])).toEqual({
file_count: 0,
file_type: 'other',
file_size_bucket: '0_1mb',
});
});
it('buckets total bytes across the 1/10/100 MB thresholds', () => {
const MB = 1024 * 1024;
const cases: Array<{ total: number; bucket: string }> = [
{ total: MB - 1, bucket: '0_1mb' },
{ total: MB, bucket: '1_10mb' },
{ total: 10 * MB - 1, bucket: '1_10mb' },
{ total: 10 * MB, bucket: '10_100mb' },
{ total: 100 * MB - 1, bucket: '10_100mb' },
{ total: 100 * MB, bucket: '100mb_plus' },
];
for (const { total, bucket } of cases) {
const cohort = deriveUploadCohort([
makeFile('blob.bin', 'application/octet-stream', total),
]);
expect(cohort.file_size_bucket, `total=${total}`).toBe(bucket);
}
});
it('sums sizes across multiple files for the bucket', () => {
const MB = 1024 * 1024;
const cohort = deriveUploadCohort([
makeFile('a.png', 'image/png', 0.6 * MB),
makeFile('b.png', 'image/png', 0.6 * MB),
]);
expect(cohort.file_size_bucket).toBe('1_10mb');
});
it('treats missing size/type defensively', () => {
const cohort = deriveUploadCohort([
{ name: 'x', type: '', size: 0 } as unknown as File,
]);
expect(cohort).toEqual({
file_count: 1,
file_type: 'other',
file_size_bucket: '0_1mb',
});
});
});