Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/LocationDownload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const doDownload = async (locations: string[]) => {
const zipFilename = ["vaxviz", "download", zipFilenameLocationPart].filter(part => part).join("_") + ".zip";

try {
await downloadCsvAsSingleOrZip("./data/csv/location_summary_tables", filenames, zipFilename);
await downloadCsvAsSingleOrZip("./data/csv/location_summary_tables", filenames, zipFilename, locations);
} catch (error) {
const message = `Error downloading summary tables: ${filenames.join(", ")}. ${error}`;
downloadErrors.value.push({ e: error as Error, message });
Expand Down
2 changes: 1 addition & 1 deletion src/components/PrivacyModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
We do not use cookies.
</p>
<p>
For these analytical purposes, we collect data on page views, session duration, device type, and location (country).
For these analytical purposes, we collect data on page views, user behaviour (downloads and plot control updates), session duration, device type, and location (country).
This data is statistically aggregated and does not identify or track individual users across sessions.
</p>
<p>
Expand Down
14 changes: 13 additions & 1 deletion src/stores/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Axis, BurdenMetric, Dimension, LocResolution } from "@/types";
import countryOptions from '@/data/options/countryOptions.json';
import subregionOptions from '@/data/options/subregionOptions.json';
import diseaseOptions from '@/data/options/diseaseOptions.json';
import useAppStoreAnalytics from '@/stores/composables/useAppStoreAnalytics';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { exploreOptions, globalOption } from "@/utils/options";

const locationOptions = [...countryOptions, ...subregionOptions, globalOption];
Expand Down Expand Up @@ -51,6 +52,18 @@ export const useAppStore = defineStore("app", () => {
};
resetLegendSelections();

useAppStoreAnalytics({
focuses,
exploreBy,
splitByActivityType,
legendSelections,
burdenMetric,
rowDimension,
columnDimension,
withinBandDimension,
logScaleEnabled,
});

const exploreByLabel = computed(() => {
const option = exploreOptions.find(o => o.value === exploreBy.value);
return option ? option.label : "";
Expand Down Expand Up @@ -145,4 +158,3 @@ export const useAppStore = defineStore("app", () => {
splitByActivityType,
};
})

54 changes: 54 additions & 0 deletions src/stores/composables/useAppStoreAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { computed, watch, type Ref } from 'vue';
import {
trackLogScaleToggle,
trackPlotControls,
type PlotAnalyticsProperties,
} from '@/utils/analytics';
import { BurdenMetric, Dimension } from '@/types';

export default ({
focuses,
exploreBy,
splitByActivityType,
legendSelections,
burdenMetric,
rowDimension,
columnDimension,
withinBandDimension,
logScaleEnabled,
}: {
focuses: Ref<string[]>,
exploreBy: Ref<Dimension.LOCATION | Dimension.DISEASE>,
splitByActivityType: Ref<boolean>,
legendSelections: Ref<Record<string, string[]>>,
burdenMetric: Ref<BurdenMetric>,
rowDimension: Ref<Dimension>,
columnDimension: Ref<Dimension | null>,
withinBandDimension: Ref<Dimension>,
logScaleEnabled: Ref<boolean>,
}) => {
const plotAnalyticsProperties = computed((): Omit<PlotAnalyticsProperties, 'logOrLinearScale'> => ({
focuses: [...focuses.value],
exploreBy: exploreBy.value,
activityTypeSplit: splitByActivityType.value ? 'split' : 'unsplit',
legendSelections: [...(legendSelections.value[exploreBy.value] ?? [])],
burdenMetric: burdenMetric.value,
rowDimension: rowDimension.value,
columnDimension: columnDimension.value,
withinBandDimension: withinBandDimension.value,
}));

// This does not fire on initial app load, only on user-initiated changes to plot controls.
watch([plotAnalyticsProperties, logScaleEnabled], ([plotProperties, logScaleEnabledValue]) => {
trackPlotControls({
...plotProperties,
logOrLinearScale: logScaleEnabledValue ? 'log' : 'linear',
});
});

watch(logScaleEnabled, (enabled) => {
trackLogScaleToggle(enabled ? 'log' : 'linear', {
...plotAnalyticsProperties.value,
});
});
};
47 changes: 47 additions & 0 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import posthog from "posthog-js";
import { locationURL } from "./externalURLs";
import { debounce } from 'perfect-debounce';

const analyticsDisabledKey: Readonly<string> = "analyticsDisabled";

Expand Down Expand Up @@ -64,3 +65,49 @@ export const initialisePosthog = () => {
}
});
};

export const trackDownload = (
downloadType: "summary_table_by_location" | "summary_table_by_filename",
additionalProperties: Record<string, string | Array<string>>,
) => {
if (!analyticsPermittedInitially) return;
posthog.capture('download', {
download_type: downloadType,
...additionalProperties,
});
Comment thread
david-mears-2 marked this conversation as resolved.
};

export type PlotAnalyticsProperties = {
focuses: string[],
exploreBy: string,
activityTypeSplit: 'split' | 'unsplit',
legendSelections?: string[],
burdenMetric: string,
rowDimension: string,
columnDimension: string | null,
withinBandDimension: string,
logOrLinearScale: 'log' | 'linear',
};

// Only capture changes to plot controls if they are stable for 8 seconds.
// This is in order to see which combinations of plot controls are most of interest,
// avoiding transient states as users navigate to their desired state.
const debounceDuration = 8000;

export const trackLogScaleToggle = debounce((
newScale: "log" | "linear",
plotProperties: Omit<PlotAnalyticsProperties, 'logOrLinearScale'>,
) => {
if (!analyticsPermittedInitially) return;
posthog.capture('log_scale_toggle', {
newScale,
...plotProperties,
});
Comment on lines +101 to +105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be delayed to ensure stability too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only fair.

}, debounceDuration);

export const trackPlotControls = debounce((plotProperties: PlotAnalyticsProperties) => {
if (!analyticsPermittedInitially) return;
posthog.capture('plot_controls_change', {
...plotProperties,
});
}, debounceDuration);
8 changes: 8 additions & 0 deletions src/utils/csvDownload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import JSZip from "jszip";
import { trackDownload } from "./analytics";

// Make a HEAD request to check if the file exists and is a CSV
const headAndCheckCsv = async (path: string) => {
Expand Down Expand Up @@ -70,7 +71,14 @@ export const downloadCsvAsSingleOrZip = async (
dataDir: string,
filenames: string[],
zipFileName: string,
locationsForAnalytics?: string[]
) => {
if (locationsForAnalytics) {
trackDownload("summary_table_by_location", { locations: locationsForAnalytics, filenames });
} else {
trackDownload("summary_table_by_filename", { filenames });
}

if (filenames.length === 1 && filenames[0]) {
await downloadSingleFile(dataDir, filenames[0]);
} else if (filenames.length > 1) {
Expand Down
59 changes: 25 additions & 34 deletions tests/e2e/downloads.spec.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,6 @@
import { expect, Download, Page } from '@playwright/test';
import { expect, Download } from '@playwright/test';
import { test } from './fixtures/interceptNetworkRequests.ts';
import { selectFocus } from './utils.ts';

const openDownloadModal = async (page: Page) => {
await page.getByRole("button", { name: "Downloads" }).click();
};

const openOtherDownloads = async (page: Page) => {
await openDownloadModal(page);
await page.getByRole('button', { name: 'Other downloads' }).click();
};

const closeDownloadModal = async (page: Page) => {
await page.getByRole("button", { name: "close", exact: true }).click();
};

// Download the file(s) that are pre-selected by default, based on the plot controls
const doDownload = async (page: Page, expectedNumberOfFiles: number): Promise<Download> => {
const downloadPromise = page.waitForEvent("download");
await openOtherDownloads(page);
await page.getByRole("button", { name: `Download ${expectedNumberOfFiles}` }).click();

const download = await downloadPromise;

// Wait for the download process to complete
await download.path();

await closeDownloadModal(page);

return download;
};
import { doDownload, selectFocus } from './utils.ts';

const readDownloadedFile = async (download: Download) => {
const readStream = await download.createReadStream();
Expand Down Expand Up @@ -91,10 +62,11 @@ test.describe("Downloads", () => {
test("can download all summary table files as a zip", async ({ page }) => {
await page.goto('/');

await openOtherDownloads(page);
await page.getByRole("button", { name: "Downloads" }).click();
await page.getByRole('button', { name: "Other downloads" }).click();

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download all available files' }).click();
await page.getByRole('button', { name: "Download all available files" }).click();
const download = await downloadPromise;
// Wait for the download process to complete
await download.path();
Expand All @@ -104,7 +76,8 @@ test.describe("Downloads", () => {
test("can download a custom selection of files using the filters", async ({ page }) => {
await page.goto('/');

await openOtherDownloads(page);
await page.getByRole("button", { name: "Downloads" }).click();
await page.getByRole('button', { name: "Other downloads" }).click();

await page.getByRole('checkbox', { name: 'DALYs averted' }).check();
await page.getByRole('checkbox', { name: 'By country' }).check();
Expand All @@ -120,4 +93,22 @@ test.describe("Downloads", () => {
const fileContents = await readDownloadedFile(download);
expect(fileContents).toContain('"disease","activity_type","country","mean_value","lower_95","upper_95","median_value"');
});

test("can download all files relating to some selected locations", async ({ page }) => {
await page.goto('/');

// Set focus to a country and subregion
const geographyRadio = page.getByRole("radio", { name: "Geography" });
await geographyRadio.click();
await selectFocus(page, "Oceania");

const downloadPromise = page.waitForEvent('download');
await page.getByRole("button", { name: "Downloads" }).click();
await page.getByRole('button', { name: "Download location-specific estimates" }).click();
await page.getByRole('button', { name: "Download estimates" }).click();
const download = await downloadPromise;
// Wait for the download process to complete
await download.path();
expect(download.suggestedFilename()).toBe("vaxviz_download_Oceania.zip");
});
});
19 changes: 18 additions & 1 deletion tests/e2e/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, Page } from "@playwright/test";
import { expect, Download, Page } from "@playwright/test";

export const selectFocus = async (page: Page, optionLabel: string) => {
await page.click(".dropdown-icon");
Expand All @@ -7,3 +7,20 @@ export const selectFocus = async (page: Page, optionLabel: string) => {
await expect(option).toBeVisible();
await option.click();
};

// Download the file(s) that are pre-selected by default, based on the plot controls
export const doDownload = async (page: Page, expectedNumberOfFiles: number): Promise<Download> => {
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Downloads" }).click();
await page.getByRole('button', { name: "Other downloads" }).click();
await page.getByRole("button", { name: `Download ${expectedNumberOfFiles}` }).click();

const download = await downloadPromise;

// Wait for the download process to complete
await download.path();

await page.getByRole("button", { name: "close", exact: true }).click();

return download;
};
1 change: 0 additions & 1 deletion tests/unit/components/DownloadSelect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ describe('DownloadSelect component', () => {

it('should call downloadCsvAsSingleOrZip with all files when "Download all" is clicked', async () => {
const downloadSpy = mockDownload();

const wrapper = mountComponent();

const downloadAllBtn = wrapper.findAll('button').find(b => b.text().includes('Download all'));
Expand Down
46 changes: 28 additions & 18 deletions tests/unit/components/LocationDownload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import DataErrorAlert from '@/components/DataErrorAlert.vue';
import { useAppStore } from '@/stores/appStore';
import * as downloadModule from '@/utils/csvDownload';

const mockDownload = () => {
return vi.spyOn(downloadModule, 'downloadCsvAsSingleOrZip').mockResolvedValue(undefined);
};

const setSelectedLocations = async (wrapper: ReturnType<typeof mount>, locations: string[]) => {
const select = wrapper.findComponent(VueSelect);
select.vm.$emit('update:modelValue', locations);
Expand All @@ -34,7 +38,7 @@ describe('LocationDownload component', () => {
});

it('downloads the expected files for a selected country', async () => {
const downloadSpy = vi.spyOn(downloadModule, 'downloadCsvAsSingleOrZip').mockResolvedValue(undefined);
const downloadSpy = mockDownload();
const wrapper = mount(LocationDownload, {
props: { menuOpen: false },
});
Expand All @@ -44,20 +48,23 @@ describe('LocationDownload component', () => {
const downloadButton = wrapper.findAll('button').find((b) => b.text().includes('Download estimates'));
await downloadButton!.trigger('click');

const expectedFiles = [
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_country_AFG.csv',
];

expect(downloadSpy).toHaveBeenCalledWith(
'./data/csv/location_summary_tables',
[
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_country_AFG.csv',
],
expectedFiles,
'vaxviz_download_AFG.zip',
['AFG'],
);
});

it('downloads the expected files for multiple selected locations', async () => {
const downloadSpy = vi.spyOn(downloadModule, 'downloadCsvAsSingleOrZip').mockResolvedValue(undefined);
const downloadSpy = mockDownload();
const wrapper = mount(LocationDownload, {
props: { menuOpen: false },
});
Expand All @@ -67,19 +74,22 @@ describe('LocationDownload component', () => {
const downloadButton = wrapper.findAll('button').find((b) => b.text().includes('Download estimates'));
await downloadButton!.trigger('click');

const expectedFiles = [
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_country_AFG.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_dalys_disease_subregion_activity_type_Central_and_Southern_Asia.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_dalys_disease_subregion_Central_and_Southern_Asia.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_deaths_disease_subregion_activity_type_Central_and_Southern_Asia.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_deaths_disease_subregion_Central_and_Southern_Asia.csv',
];

expect(downloadSpy).toHaveBeenCalledWith(
'./data/csv/location_summary_tables',
[
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_dalys_disease_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_activity_type_country_AFG.csv',
'country_summary_tables/AFG_summary_tables/summary_table_deaths_disease_country_AFG.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_dalys_disease_subregion_activity_type_Central_and_Southern_Asia.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_dalys_disease_subregion_Central_and_Southern_Asia.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_deaths_disease_subregion_activity_type_Central_and_Southern_Asia.csv',
'subregion_summary_tables/Central_and_Southern_Asia_summary_tables/summary_table_deaths_disease_subregion_Central_and_Southern_Asia.csv',
],
expectedFiles,
'vaxviz_download.zip',
['AFG', 'Central and Southern Asia'],
);
});

Expand Down
Loading
Loading