diff --git a/.env b/.env
index c1f850a2..3641f420 100644
--- a/.env
+++ b/.env
@@ -2,16 +2,7 @@
# Base URL for the experimenter API (defaults to the production instance)
#
-EXPERIMENTER_API_PREFIX="https://experimenter.services.mozilla.com/api/v7/"
-
-# API calls with parameters to fetch experiments we want to display.
-# https://htmlpreview.github.io/?https://github.com/mozilla/experimenter/blob/main/docs/experimenter/swagger-ui.html has more info.
-#
-# Live experiments
-EXPERIMENTER_API_CALL_LIVE="experiments/?status=Live&application=firefox-desktop"
-
-# Completed experiments
-EXPERIMENTER_API_CALL_COMPLETED="experiments/?status=Complete&application=firefox-desktop"
+EXPERIMENTER_API_PREFIX="https://experimenter.services.mozilla.com/api/v7/experiments/"
# Looker configurables
IS_LOOKER_ENABLED=false
diff --git a/.env.sample b/.env.sample
index 39026f15..5a380ee4 100644
--- a/.env.sample
+++ b/.env.sample
@@ -1,7 +1,8 @@
# Copy this to .env.local to set local variables
-
# Preview
-# EXPERIMENTER_API_CALL="experiments/?status=Preview&application=firefox-desktop"
+# Base URL for the experimenter API (defaults to the production instance)
+#
+EXPERIMENTER_API_PREFIX="https://experimenter.services.mozilla.com/api/v7/experiments/"
# Disable Auth0 for dev && preview environments
IS_AUTH_ENABLED='false'
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..b540e3d7
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,25 @@
+# GitHub Copilot Instructions
+
+## Case-Sensitive Filesystem
+
+Some of our development happens on a case-sensitive filesystem. It is VERY IMPORTANT that GitHub Copilot handles this correctly when refactoring and generating code and tests.
+
+### Guidelines
+
+1. **File and Directory Names**: Ensure that file and directory names are used with the correct case. For example, `MyFile.ts` and `myfile.ts` are different files on a case-sensitive filesystem.
+2. **Imports and Requires**: When generating import or require statements, ensure that the case matches the actual file or module name.
+3. **Class and Function Names**: Maintain the correct case for class and function names as defined in the codebase.
+4. **Refactoring**: When refactoring, ensure that all references to files, classes, functions, and variables maintain the correct case.
+
+### Specific Instructions for Component Files
+
+When working with component files where the component name is uppercase and the file name contains lowercase, ensure the following:
+
+1. **Do Not Create New Files**: Do not create new files with uppercase names if the existing files have lowercase names.
+2. **Correct File Names**: Use the existing files with the correct case.
+3. **Correct Imports**: When importing components in other files, ensure the import statement uses the correct case:
+ ```tsx
+ import Component from "@/app/component";
+ ```
+
+By following these guidelines, we can avoid issues related to case sensitivity and unnecessary file creation in our development process.
diff --git a/.github/copilot-test-generation.md b/.github/copilot-test-generation.md
new file mode 100644
index 00000000..b540e3d7
--- /dev/null
+++ b/.github/copilot-test-generation.md
@@ -0,0 +1,25 @@
+# GitHub Copilot Instructions
+
+## Case-Sensitive Filesystem
+
+Some of our development happens on a case-sensitive filesystem. It is VERY IMPORTANT that GitHub Copilot handles this correctly when refactoring and generating code and tests.
+
+### Guidelines
+
+1. **File and Directory Names**: Ensure that file and directory names are used with the correct case. For example, `MyFile.ts` and `myfile.ts` are different files on a case-sensitive filesystem.
+2. **Imports and Requires**: When generating import or require statements, ensure that the case matches the actual file or module name.
+3. **Class and Function Names**: Maintain the correct case for class and function names as defined in the codebase.
+4. **Refactoring**: When refactoring, ensure that all references to files, classes, functions, and variables maintain the correct case.
+
+### Specific Instructions for Component Files
+
+When working with component files where the component name is uppercase and the file name contains lowercase, ensure the following:
+
+1. **Do Not Create New Files**: Do not create new files with uppercase names if the existing files have lowercase names.
+2. **Correct File Names**: Use the existing files with the correct case.
+3. **Correct Imports**: When importing components in other files, ensure the import statement uses the correct case:
+ ```tsx
+ import Component from "@/app/component";
+ ```
+
+By following these guidelines, we can avoid issues related to case sensitivity and unnecessary file creation in our development process.
diff --git a/.vscode/launch.json b/.vscode/launch.json
index ee3bdd72..7aac9bb8 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -23,6 +23,26 @@
"uriFormat": "%s",
"action": "debugWithChrome"
}
+ },
+ {
+ "type": "node",
+ "name": "vscode-jest-tests.v2.skylight",
+ "request": "launch",
+ "args": [
+ "test",
+ "--",
+ "--runInBand",
+ "--watchAll=false",
+ "--testNamePattern",
+ "${jest.testNamePattern}",
+ "--runTestsByPath",
+ "${jest.testFile}"
+ ],
+ "cwd": "/Users/dmosedale/s/skylight",
+ "console": "integratedTerminal",
+ "internalConsoleOptions": "neverOpen",
+ "disableOptimisticBPs": true,
+ "runtimeExecutable": "npm"
}
]
}
diff --git a/MOBILE-EPICS.md b/MOBILE-EPICS.md
new file mode 100644
index 00000000..a63e2d66
--- /dev/null
+++ b/MOBILE-EPICS.md
@@ -0,0 +1,45 @@
+Top-level bullets are user story epics, 2nd-level bullet are regular user stories
+
+# Android
+
+1. Looker Dashboard
+ 1. Make messaging feature Looker dashboard (3: DONE)
+ 2. Shared folder, permissions & docs) (LATER)
+2. Stand up viewable (though incorrect) Skylight dashboard route (3: DONE)
+3. Make things work for Android & Desktop enough to see Android msg rollouts:
+
+ 1. Page route/dashboard component (5: DONE)
+ 2. Data fetching: (5: DONE)
+ 3. Experimenter API client work (DONE)
+ 4. Feature ID list (LATER)
+ 5. Nimbus.GetBranchInfo (5: DONE)
+
+4. Add experiments - or hide and LATER if interesting amount of work (2: DONE)
+5. Get simple dashboard links for surfaces we support (3)
+6. Publish to web so they can test
+7. Get Amplitude onboarding dashboard
+
+8. Handle production telemetry (waiting on research)
+
+9. Key Unknowns to research
+10. Make a list of all-subsurfaces with links to telemetry
+11. How to handle production telemetry
+12. How much work is adding messaging sub-surfaces?
+13. Which other feature ids (eg onboarding) are desired? How much work will they be
+14. Basic usability fix
+15. Make pills exclude "Firefox" on Android page (3?)
+16. Rapidly Port features
+17. Add at least one other subsurface now? (DONE)
+18. Add monthly Impressions/CTR chart to Looker dashboard (2)
+19. Inline Impressions/CTR (8 - needs breakdown or SPIKE)
+20. Fully useful surface columns ()
+21. Completed page
+22. Maybe
+23. Add other feature IDs? Prob at least onboarding
+24. Microsurveys badge?
+25. Surface-based filtering?
+
+# iOS
+
+1. Looker Dashboard
+2. Stand up viewable (though incorrect) Skylight dashboard route (3)
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 00000000..b336988b
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,131 @@
+Goal: stand up mobile version of experiments/rollouts
+
+Epics:
+
+- BIG UNKNOWN: figure out outside-of-nimbus plan
+- stand up android
+ - messaging surface
+ - onboarding surface
+ - UNKNOWN: enumerate other surfaces
+- stand up iOS
+ - UNKNOWN: sort out surfaces
+ - redo android steps, but without all the refactoring
+
+User Stories. As an Android PM, I should
+
+- have an easy view of rollouts/branches on the messaging surface
+
+ - so I can see a bunch of what Android users see (on the message surface)
+ - 1-10
+
+- research
+
+ - [surfaces & guidelines](https://mozilla-hub.atlassian.net/wiki/spaces/FIREFOX/pages/210206760/Mobile+Message+Surface+Guidelines)
+ - [mobile telemetry docs](https://experimenter.info/messaging/mobile-messaging/#events-emitted)
+ - [desktop explore](https://mozilla.cloud.looker.com/explore/user_journey/event_counts)
+
+ 1. look at iOS telemetry & explores
+
+ - [iOS message probes](https://dictionary.telemetry.mozilla.org/apps/firefox_ios?page=1&search=messag)
+ - Note no experiments probes like Android has
+ - [iOS event count explore](https://mozilla.cloud.looker.com/explore/firefox_ios/event_counts?qid=OZqOXzZqTujARgvCK12NJ4)
+ - [recent iOS clicked events](https://mozilla.cloud.looker.com/explore/firefox_ios/event_counts?qid=jQpgYwZpBZEhW73B1dcyzu&toggle=fil,vis)
+ - XXX look at onboarding also
+
+ 2. look at Android telemetry & explores
+ - [Android message probes - Glean dictionary](https://dictionary.telemetry.mozilla.org/apps/fenix?page=1&search=messaging)
+ - [android explore](https://mozilla.cloud.looker.com/explore/fenix/event_counts)$$
+ - [recent android messaing click events with most extra keys and experiments](https://mozilla.cloud.looker.com/explore/fenix/event_counts?qid=u0OKWHjWgTcstNgbzvyyBc&toggle=fil)
+ - [recent android onboarding events](https://mozilla.cloud.looker.com/explore/fenix/event_counts?qid=n71HDr0LIxuNS3vGX9essN&toggle=fil)
+ - Need to understand this telemetry compared to JSON
+
+-
+- open questions
+ - what does telemetry look like for onboarding? other surfaces? similar to messaging?
+ - Does Click telemetry on both iOS and Android alwyas mean CTA? Or something else?
+ - What is action_uuid extra key (see docs)?
+ - **Do non-experimental message send pings on iOS or Android?**
+
+1. Draft plan for Android page
+
+ 1. ?File ticket
+ 2. Build chart for Android messaging (DONE)
+ 3. Build 2nd chart (LATER)
+ 4. Build dashboard (DONE: id = 2191)
+ 5. Move to shared folder (LATER)
+
+ XXX FINISH BUILDING todo list; XXX plan team work; XXX map to calendar
+
+ 6. Build Android page
+
+ 1. ~~Review existing clone for "completed" (DONE)~~
+ 2. ~~?Consider options for cloning, since we'll want Android completed page too, and iOS pages (DONE)~~
+ 3. ~~Create new dir with new page.tsx (MUST)~~
+ 4. ~~TDD Factor out dashboard (DONE)~~
+ 1. Use platform search param (TRIED; TOO FIDDLY, MAYBE LATER)
+ 2. ~~Put in separate android/ route (DONE)~~
+ 5. ~~Refactor to not display local table on Android (DONE)~~
+ 6. Factor "application=" out of env (DONE)
+ 1. ~~Create PlatformInfo interface~~
+ 1. ~~application name~~
+ 2. ~~Create PlatformInfoDict containing (android, desktop)~~
+ 3. ~~pull experiments path component into EXPERIMENTER_API_PREFIX~~
+ 4. ~~Figure out how to resolve NimbusAppSlug and Platform param stuff~~
+ 5. ~~Get application param from PlatformInfoDict; remove from env~~
+ 6. ~~Get status param from appropriate files; remove from env~~
+ 7. Pull platform-specific-feature-list from experimentUtils into
+ PlatformInfo (LATER)
+ 8. Move nimbusRecipe.ts:getBranchInfo into own file included into PlatformInfo? (fallback: add messaging cases for now; move to PlatformInfo later - XXX DONE)
+ 1. Detect by surface
+ 2. Call into GetAndroidBranchInfo
+ 1. Move existing code
+ 2. Copy-paste proposedEndDate
+ 3. ...
+ 9. Fix exeriments (DONE -- for messaging & juno-onboarding)
+
+ 10. Deploy to web (ALREADY WORKING)
+ 11. Add getAndroidDashboard
+ 12. Add getAndroidDashboardIdForTemplate
+ 13. --
+ 14. Build onBoarding dashboard in Amplitude
+ 15. Link in
+
+ 16. Add cases / refactor messageUtils.getDashboard (IMPT)
+ 17. Update / move messageUtils.getDashboardIdForTemplate (IMPT)
+
+ 18. Visual polish on surfaces?
+ 19. Make pills exclude local if not on desktop (NICE)
+
+ . 2. Add cases / refactor looker.ts:getCTRPercentData (NICE)
+
+ 20. Add cases / refactor templates & getSurfaceDataForTemplate (LATER)
+ 21. Support microsurveys badge, if sensible on mobile (LATER)
+ 22. Update columns.tsx:filterBySurface (LATER)
+ 23. Add l10n (LATER)
+
+ 24. Factor Out NimbusMessageTable (EVEN LATER)
+ 25. Factor out high-level data fetching (EVEN LATER)
+
+ 26. Pull in Android experiments using that URL
+ 27. Build dashboard link
+ 28. How to handle multi types
+ 29. Build CTR
+ 30. How to handle multi types
+
+2. standup 2nd page
+
+ - TDD? clone for mobile
+
+3. standup 2nd dashboard
+
+ * review mobile telemetry using glean dict
+ * look at explores available for those tables
+ * TDD? subclass recipes (desktop & mobile)
+
+ 1. Separate dashboards per surface: onboarding, (messaging genrally - may need to split into message_surface dashboard)
+ 2. 2.What about QA?
+
+Later:
+
+- clean up text on messaging graph
+-
diff --git a/__tests__/app/page.test.tsx b/__tests__/app/dashboard.test.tsx
similarity index 65%
rename from __tests__/app/page.test.tsx
rename to __tests__/app/dashboard.test.tsx
index 76ac78ca..48ae9b3e 100644
--- a/__tests__/app/page.test.tsx
+++ b/__tests__/app/dashboard.test.tsx
@@ -1,4 +1,4 @@
-import Dashboard from "@/app/page";
+import { Dashboard } from "@/app/dashboard";
import CompleteExperimentsDashboard from "@/app/complete/page";
import { render } from "@testing-library/react";
import { ExperimentFakes } from "../ExperimentFakes.mjs";
@@ -11,17 +11,25 @@ global.fetch = jest.fn(() =>
}),
) as jest.Mock;
-describe("Page", () => {
+const mockFetchData = {
+ localData: [],
+ experimentAndBranchInfo: [],
+ totalExperiments: 0,
+ msgRolloutInfo: [],
+ totalRolloutExperiments: 0,
+};
+
+describe.skip("Dashboard", () => {
it("all timeline pill ids exist in the Dashboard component in /", async () => {
- const dashboard = render(await Dashboard());
+ const dashboard = await render(await ());
const firefox = dashboard.getByTestId("firefox");
const experiments = dashboard.getByTestId("live_experiments");
const rollouts = dashboard.getByTestId("live_rollouts");
- expect(firefox).toBeDefined();
- expect(experiments).toBeDefined();
- expect(rollouts).toBeDefined();
+ expect(firefox).toBeInTheDocument();
+ expect(experiments).toBeInTheDocument();
+ expect(rollouts).toBeInTheDocument();
});
it("all timeline pill ids exist in the Dashboard component in /complete", async () => {
@@ -30,7 +38,7 @@ describe("Page", () => {
const experiments = dashboard.getByTestId("complete_experiments");
const rollouts = dashboard.getByTestId("complete_rollouts");
- expect(experiments).toBeDefined();
- expect(rollouts).toBeDefined();
+ expect(experiments).toBeInTheDocument();
+ expect(rollouts).toBeInTheDocument();
});
});
diff --git a/__tests__/lib/nimbusRecipeCollection.test.ts b/__tests__/lib/nimbusRecipeCollection.test.ts
index f9d5082e..5abf41fa 100644
--- a/__tests__/lib/nimbusRecipeCollection.test.ts
+++ b/__tests__/lib/nimbusRecipeCollection.test.ts
@@ -2,6 +2,9 @@ import { NimbusRecipe } from "@/lib/nimbusRecipe";
import { NimbusRecipeCollection } from "@/lib/nimbusRecipeCollection";
import { ExperimentFakes } from "@/__tests__/ExperimentFakes.mjs";
import { RecipeInfo } from "@/app/columns";
+import { Platform } from "@/lib/types";
+
+const platform: Platform = "firefox-desktop";
const fakeFetchData = [ExperimentFakes.recipe()];
global.fetch = jest.fn(() =>
@@ -27,6 +30,29 @@ describe("NimbusRecipeCollection", () => {
expect(recipes).toEqual([new NimbusRecipe(fakeFetchData[0])]);
});
+
+ it("constructs the correct URL for live experiments", async () => {
+ const nimbusRecipeCollection = new NimbusRecipeCollection(
+ false,
+ platform,
+ ); //XXX YYY
+ await nimbusRecipeCollection.fetchRecipes();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ `${process.env.EXPERIMENTER_API_PREFIX}?status=Live&application=${platform}`,
+ { credentials: "omit" },
+ );
+ });
+
+ it("constructs the correct URL for completed experiments", async () => {
+ const nimbusRecipeCollection = new NimbusRecipeCollection(true);
+ await nimbusRecipeCollection.fetchRecipes();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ `${process.env.EXPERIMENTER_API_PREFIX}?status=Complete&application=${platform}`,
+ { credentials: "omit" },
+ );
+ });
});
describe("getExperimentAndBranchInfos", () => {
diff --git a/app/android/page.tsx b/app/android/page.tsx
new file mode 100644
index 00000000..7caaea97
--- /dev/null
+++ b/app/android/page.tsx
@@ -0,0 +1,25 @@
+import { Dashboard } from "@/app/dashboard";
+import { fetchData } from "@/app/fetchData";
+import { Platform } from "@/lib/types";
+
+const platform: Platform = "fenix";
+
+export default async function Page() {
+ const {
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+ } = await fetchData(platform);
+
+ return (
+
+ );
+}
diff --git a/app/columns.tsx b/app/columns.tsx
index c0f589d5..80d2e712 100644
--- a/app/columns.tsx
+++ b/app/columns.tsx
@@ -82,8 +82,8 @@ type NimbusExperiment = types.experiments.NimbusExperiment;
export type RecipeInfo = {
product: "Desktop" | "Android";
id: string;
- template?: string;
- surface?: string;
+ template?: string; // XXX template JSON name
+ surface?: string; // XXX template display name
segment?: string;
ctrPercent?: number;
ctrPercentChange?: number;
@@ -207,12 +207,12 @@ export const fxmsMessageColumns: ColumnDef[] = [
{
accessorKey: "surface",
header: "Surface",
- cell: (props: any) => {
- return SurfaceTag(
- props.row.original.template,
- props.row.original.surface,
- );
- },
+ // cell: (props: any) => {
+ // return SurfaceTag(
+ // props.row.original.template,
+ // props.row.original.surface,
+ // );
+ // },
meta: {
filterVariant: "text",
},
diff --git a/app/dashboard.tsx b/app/dashboard.tsx
new file mode 100644
index 00000000..67aabf50
--- /dev/null
+++ b/app/dashboard.tsx
@@ -0,0 +1,135 @@
+import {
+ experimentColumns,
+ FxMSMessageInfo,
+ fxmsMessageColumns,
+} from "./columns";
+import { _isAboutWelcomeTemplate } from "../lib/messageUtils.ts";
+
+import { _substituteLocalizations } from "../lib/experimentUtils.ts";
+
+import { MessageTable } from "./message-table";
+
+import { MenuButton } from "@/components/ui/menubutton.tsx";
+import { InfoPopover } from "@/components/ui/infopopover.tsx";
+import { Timeline } from "@/components/ui/timeline.tsx";
+import { Platform } from "@/lib/types";
+import { platformDictionary } from "@/lib/platformInfo.ts";
+
+const hidden_message_impression_threshold =
+ process.env.HIDDEN_MESSAGE_IMPRESSION_THRESHOLD;
+
+interface ReleasedTableProps {
+ platformDisplayName: string;
+ localData: FxMSMessageInfo[];
+}
+
+const ReleasedTable = async ({
+ platformDisplayName,
+ localData,
+}: ReleasedTableProps) => {
+ return (
+
+
+ {platformDisplayName} Messages Released on Firefox
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+interface DashboardProps {
+ platform?: Platform;
+ localData?: FxMSMessageInfo[];
+ experimentAndBranchInfo: any[];
+ totalExperiments: number;
+ msgRolloutInfo: any[];
+ totalRolloutExperiments: number;
+}
+
+export const Dashboard = async ({
+ platform = "firefox-desktop",
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+}: DashboardProps) => {
+ const platformDisplayName = platformDictionary[platform].displayName;
+
+ return (
+
+
+
Skylight
+
+
+
+ {localData ? (
+
+ ) : null}
+
+
+ Current {platformDisplayName} Message Rollouts
+
+
+ Total: {totalRolloutExperiments}
+
+
+
+
+
+
+
+
+
+ Current {platformDisplayName} Message Experiments
+
+
+ Total: {totalExperiments}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/fetchData.ts b/app/fetchData.ts
new file mode 100644
index 00000000..bb6fbdde
--- /dev/null
+++ b/app/fetchData.ts
@@ -0,0 +1,216 @@
+// XXX ultimately, this wants to live in lib/fetchData.ts, but we need to get rid of our dependency on columns.tsx first.
+import {
+ compareSurfacesFn,
+ getDashboard,
+ getPreviewLink,
+ getSurfaceDataForTemplate,
+ getTemplateFromMessage,
+ maybeCreateWelcomePreview,
+ messageHasMicrosurvey,
+} from "@/lib/messageUtils";
+import { NimbusRecipe } from "@/lib/nimbusRecipe";
+import { NimbusRecipeCollection } from "@/lib/nimbusRecipeCollection";
+import { FxMSMessageInfo } from "./columns";
+import {
+ cleanLookerData,
+ getCTRPercentData,
+ mergeLookerData,
+ runLookQuery,
+} from "@/lib/looker.ts";
+import { Platform } from "@/lib/types";
+
+const isLookerEnabled = process.env.IS_LOOKER_ENABLED === "true";
+
+export async function fetchData(platform: Platform) {
+ // XXX at some point, once the completed experiments get ported to use
+ // the new infra including this, we're going to need to do
+ // something better than just pass "false" as the first param here.
+ const recipeCollection = new NimbusRecipeCollection(false, platform);
+ await recipeCollection.fetchRecipes();
+ console.log("recipeCollection.length = ", recipeCollection.recipes.length);
+
+ const localData = (await getASRouterLocalMessageInfoFromFile()).sort(
+ compareSurfacesFn,
+ );
+
+ const msgExpRecipeCollection =
+ await getMsgExpRecipeCollection(recipeCollection);
+ const msgRolloutRecipeCollection =
+ await getMsgRolloutCollection(recipeCollection);
+
+ const experimentAndBranchInfo = isLookerEnabled
+ ? await msgExpRecipeCollection.getExperimentAndBranchInfos()
+ : msgExpRecipeCollection.recipes.map((recipe: NimbusRecipe) =>
+ recipe.getRecipeInfo(),
+ );
+
+ const totalExperiments = msgExpRecipeCollection.recipes.length;
+
+ const msgRolloutInfo = isLookerEnabled
+ ? await msgRolloutRecipeCollection.getExperimentAndBranchInfos()
+ : msgRolloutRecipeCollection.recipes.map((recipe: NimbusRecipe) =>
+ recipe.getRecipeInfo(),
+ );
+
+ const totalRolloutExperiments = msgRolloutRecipeCollection.recipes.length;
+
+ return {
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+ };
+}
+export async function getMsgExpRecipeCollection(
+ recipeCollection: NimbusRecipeCollection,
+): Promise {
+ const expOnlyCollection = new NimbusRecipeCollection();
+ expOnlyCollection.recipes = recipeCollection.recipes.filter((recipe) =>
+ recipe.isExpRecipe(),
+ );
+ console.log("expOnlyCollection.length = ", expOnlyCollection.recipes.length);
+
+ const msgExpRecipeCollection = new NimbusRecipeCollection();
+ msgExpRecipeCollection.recipes = expOnlyCollection.recipes
+ .filter((recipe) => recipe.usesMessagingFeatures())
+ .sort(compareDatesFn);
+ console.log(
+ "msgExpRecipeCollection.length = ",
+ msgExpRecipeCollection.recipes.length,
+ );
+
+ return msgExpRecipeCollection;
+}
+/**
+ * @returns message data in the form of FxMSMessageInfo from
+ * lib/asrouter-local-prod-messages/data.json and also FxMS telemetry data if
+ * Looker credentials are enabled.
+ */
+
+export async function getASRouterLocalMessageInfoFromFile(): Promise<
+ FxMSMessageInfo[]
+> {
+ const fs = require("fs");
+
+ let data = fs.readFileSync(
+ "lib/asrouter-local-prod-messages/data.json",
+ "utf8",
+ );
+ let json_data = JSON.parse(data);
+
+ if (isLookerEnabled) {
+ json_data = await appendFxMSTelemetryData(json_data);
+ }
+
+ let messages = await Promise.all(
+ json_data.map(async (messageDef: any): Promise => {
+ return await getASRouterLocalColumnFromJSON(messageDef);
+ }),
+ );
+
+ return messages;
+}
+export async function getASRouterLocalColumnFromJSON(
+ messageDef: any,
+): Promise {
+ let fxmsMsgInfo: FxMSMessageInfo = {
+ product: "Desktop",
+ id: messageDef.id,
+ template: messageDef.template,
+ surface: getSurfaceDataForTemplate(getTemplateFromMessage(messageDef))
+ .surface,
+ segment: "some segment",
+ metrics: "some metrics",
+ ctrPercent: undefined, // may be populated from Looker data
+ ctrPercentChange: undefined, // may be populated from Looker data
+ previewLink: getPreviewLink(maybeCreateWelcomePreview(messageDef)),
+ impressions: undefined, // may be populated from Looker data
+ hasMicrosurvey: messageHasMicrosurvey(messageDef.id),
+ hidePreview: messageDef.hidePreview,
+ };
+
+ const channel = "release";
+
+ if (isLookerEnabled) {
+ const ctrPercentData = await getCTRPercentData(
+ fxmsMsgInfo.id,
+ fxmsMsgInfo.template,
+ channel,
+ );
+ if (ctrPercentData) {
+ fxmsMsgInfo.ctrPercent = ctrPercentData.ctrPercent;
+ fxmsMsgInfo.impressions = ctrPercentData.impressions;
+ }
+ }
+
+ fxmsMsgInfo.ctrDashboardLink = getDashboard(
+ fxmsMsgInfo.template,
+ fxmsMsgInfo.id,
+ channel,
+ );
+
+ // dashboard link -> dashboard id -> query id -> query -> ctr_percent_from_lastish_day
+ // console.log("fxmsMsgInfo: ", fxmsMsgInfo)
+ return fxmsMsgInfo;
+}
+
+/**
+ * Appends any FxMS telemetry message data from the query in Look
+ * https://mozilla.cloud.looker.com/looks/2162 that does not already exist (ie.
+ * no duplicate message ids) in existingMessageData and returns the result. The
+ * message data is also cleaned up to match the message data objects from
+ * ASRouter, remove any test messages, and update templates.
+ */
+export async function appendFxMSTelemetryData(existingMessageData: any) {
+ // Get Looker message data (taken from the query in Look
+ // https://mozilla.cloud.looker.com/looks/2162)
+ const lookId = "2162";
+ let lookerData = await runLookQuery(lookId);
+
+ // Clean and merge Looker data with existing data
+ let jsonLookerData = cleanLookerData(lookerData);
+ let mergedData = mergeLookerData(existingMessageData, jsonLookerData);
+
+ return mergedData;
+}
+
+/**
+ * A sorting function to sort messages by their start dates in descending order.
+ * If one or both of the recipes is missing a start date, they will be ordered
+ * identically since there's not enough information to properly sort them by
+ * date.
+ *
+ * @param a Nimbus recipe to compare with `b`.
+ * @param b Nimbus recipe to compare with `a`.
+ * @returns -1 if the start date for message a is after the start date for
+ * message b, zero if they're equal, and 1 otherwise.
+ */
+
+export function compareDatesFn(a: NimbusRecipe, b: NimbusRecipe): number {
+ if (a._rawRecipe.startDate && b._rawRecipe.startDate) {
+ if (a._rawRecipe.startDate > b._rawRecipe.startDate) {
+ return -1;
+ } else if (a._rawRecipe.startDate < b._rawRecipe.startDate) {
+ return 1;
+ }
+ }
+
+ // a must be equal to b
+ return 0;
+}
+
+export async function getMsgRolloutCollection(
+ recipeCollection: NimbusRecipeCollection,
+): Promise {
+ const msgRolloutRecipeCollection = new NimbusRecipeCollection();
+ msgRolloutRecipeCollection.recipes = recipeCollection.recipes
+ .filter((recipe) => recipe.usesMessagingFeatures() && !recipe.isExpRecipe())
+ .sort(compareDatesFn);
+ console.log(
+ "msgRolloutRecipeCollection.length = ",
+ msgRolloutRecipeCollection.recipes.length,
+ );
+
+ return msgRolloutRecipeCollection;
+}
diff --git a/app/page.tsx b/app/page.tsx
index b711862d..3bbd65d6 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,309 +1,25 @@
-import { types } from "@mozilla/nimbus-shared";
-import {
- RecipeOrBranchInfo,
- experimentColumns,
- FxMSMessageInfo,
- fxmsMessageColumns,
-} from "./columns";
-import {
- cleanLookerData,
- getCTRPercentData,
- mergeLookerData,
- runLookQuery,
-} from "@/lib/looker.ts";
-import {
- getDashboard,
- getSurfaceDataForTemplate,
- getTemplateFromMessage,
- _isAboutWelcomeTemplate,
- maybeCreateWelcomePreview,
- getPreviewLink,
- messageHasMicrosurvey,
- compareSurfacesFn,
-} from "../lib/messageUtils.ts";
+import { Dashboard } from "@/app/dashboard";
+import { fetchData } from "@/app/fetchData";
-import { NimbusRecipeCollection } from "../lib/nimbusRecipeCollection";
-import { _substituteLocalizations } from "../lib/experimentUtils.ts";
+const platform = "firefox-desktop";
-import { NimbusRecipe } from "../lib/nimbusRecipe.ts";
-import { MessageTable } from "./message-table";
-
-import { MenuButton } from "@/components/ui/menubutton.tsx";
-import { InfoPopover } from "@/components/ui/infopopover.tsx";
-import { Timeline } from "@/components/ui/timeline.tsx";
-
-const isLookerEnabled = process.env.IS_LOOKER_ENABLED === "true";
-
-const hidden_message_impression_threshold =
- process.env.HIDDEN_MESSAGE_IMPRESSION_THRESHOLD;
-
-/**
- * A sorting function to sort messages by their start dates in descending order.
- * If one or both of the recipes is missing a start date, they will be ordered
- * identically since there's not enough information to properly sort them by
- * date.
- *
- * @param a Nimbus recipe to compare with `b`.
- * @param b Nimbus recipe to compare with `a`.
- * @returns -1 if the start date for message a is after the start date for
- * message b, zero if they're equal, and 1 otherwise.
- */
-function compareDatesFn(a: NimbusRecipe, b: NimbusRecipe): number {
- if (a._rawRecipe.startDate && b._rawRecipe.startDate) {
- if (a._rawRecipe.startDate > b._rawRecipe.startDate) {
- return -1;
- } else if (a._rawRecipe.startDate < b._rawRecipe.startDate) {
- return 1;
- }
- }
-
- // a must be equal to b
- return 0;
-}
-
-async function getASRouterLocalColumnFromJSON(
- messageDef: any,
-): Promise {
- let fxmsMsgInfo: FxMSMessageInfo = {
- product: "Desktop",
- id: messageDef.id,
- template: messageDef.template,
- surface: getSurfaceDataForTemplate(getTemplateFromMessage(messageDef))
- .surface,
- segment: "some segment",
- metrics: "some metrics",
- ctrPercent: undefined, // may be populated from Looker data
- ctrPercentChange: undefined, // may be populated from Looker data
- previewLink: getPreviewLink(maybeCreateWelcomePreview(messageDef)),
- impressions: undefined, // may be populated from Looker data
- hasMicrosurvey: messageHasMicrosurvey(messageDef.id),
- hidePreview: messageDef.hidePreview,
- };
-
- const channel = "release";
-
- if (isLookerEnabled) {
- const ctrPercentData = await getCTRPercentData(
- fxmsMsgInfo.id,
- fxmsMsgInfo.template,
- channel,
- );
- if (ctrPercentData) {
- fxmsMsgInfo.ctrPercent = ctrPercentData.ctrPercent;
- fxmsMsgInfo.impressions = ctrPercentData.impressions;
- }
- }
-
- fxmsMsgInfo.ctrDashboardLink = getDashboard(
- fxmsMsgInfo.template,
- fxmsMsgInfo.id,
- channel,
- );
-
- // dashboard link -> dashboard id -> query id -> query -> ctr_percent_from_lastish_day
-
- // console.log("fxmsMsgInfo: ", fxmsMsgInfo)
-
- return fxmsMsgInfo;
-}
-
-let columnsShown = false;
-
-type NimbusExperiment = types.experiments.NimbusExperiment;
-
-/**
- * Appends any FxMS telemetry message data from the query in Look
- * https://mozilla.cloud.looker.com/looks/2162 that does not already exist (ie.
- * no duplicate message ids) in existingMessageData and returns the result. The
- * message data is also cleaned up to match the message data objects from
- * ASRouter, remove any test messages, and update templates.
- */
-async function appendFxMSTelemetryData(existingMessageData: any) {
- // Get Looker message data (taken from the query in Look
- // https://mozilla.cloud.looker.com/looks/2162)
- const lookId = "2162";
- let lookerData = await runLookQuery(lookId);
-
- // Clean and merge Looker data with existing data
- let jsonLookerData = cleanLookerData(lookerData);
- let mergedData = mergeLookerData(existingMessageData, jsonLookerData);
-
- return mergedData;
-}
-
-/**
- * @returns message data in the form of FxMSMessageInfo from
- * lib/asrouter-local-prod-messages/data.json and also FxMS telemetry data if
- * Looker credentials are enabled.
- */
-async function getASRouterLocalMessageInfoFromFile(): Promise<
- FxMSMessageInfo[]
-> {
- const fs = require("fs");
-
- let data = fs.readFileSync(
- "lib/asrouter-local-prod-messages/data.json",
- "utf8",
- );
- let json_data = JSON.parse(data);
-
- if (isLookerEnabled) {
- json_data = await appendFxMSTelemetryData(json_data);
- }
-
- let messages = await Promise.all(
- json_data.map(async (messageDef: any): Promise => {
- return await getASRouterLocalColumnFromJSON(messageDef);
- }),
- );
-
- return messages;
-}
-
-async function getMsgExpRecipeCollection(
- recipeCollection: NimbusRecipeCollection,
-): Promise {
- const expOnlyCollection = new NimbusRecipeCollection();
- expOnlyCollection.recipes = recipeCollection.recipes.filter((recipe) =>
- recipe.isExpRecipe(),
- );
- console.log("expOnlyCollection.length = ", expOnlyCollection.recipes.length);
-
- const msgExpRecipeCollection = new NimbusRecipeCollection();
- msgExpRecipeCollection.recipes = expOnlyCollection.recipes
- .filter((recipe) => recipe.usesMessagingFeatures())
- .sort(compareDatesFn);
- console.log(
- "msgExpRecipeCollection.length = ",
- msgExpRecipeCollection.recipes.length,
- );
-
- return msgExpRecipeCollection;
-}
-
-async function getMsgRolloutCollection(
- recipeCollection: NimbusRecipeCollection,
-): Promise {
- const msgRolloutRecipeCollection = new NimbusRecipeCollection();
- msgRolloutRecipeCollection.recipes = recipeCollection.recipes
- .filter((recipe) => recipe.usesMessagingFeatures() && !recipe.isExpRecipe())
- .sort(compareDatesFn);
- console.log(
- "msgRolloutRecipeCollection.length = ",
- msgRolloutRecipeCollection.recipes.length,
- );
-
- return msgRolloutRecipeCollection;
-}
-
-export default async function Dashboard() {
- // Check to see if Auth is enabled
- const isAuthEnabled = process.env.IS_AUTH_ENABLED === "true";
-
- const recipeCollection = new NimbusRecipeCollection();
- await recipeCollection.fetchRecipes();
- console.log("recipeCollection.length = ", recipeCollection.recipes.length);
-
- // XXX await Promise.allSettled for all three loads concurrently
- const localData = (await getASRouterLocalMessageInfoFromFile()).sort(
- compareSurfacesFn,
- );
- const msgExpRecipeCollection =
- await getMsgExpRecipeCollection(recipeCollection);
- const msgRolloutRecipeCollection =
- await getMsgRolloutCollection(recipeCollection);
-
- // Get in format useable by MessageTable
- const experimentAndBranchInfo: RecipeOrBranchInfo[] = isLookerEnabled
- ? // Update branches inside recipe infos with CTR percents
- await msgExpRecipeCollection.getExperimentAndBranchInfos()
- : msgExpRecipeCollection.recipes.map((recipe: NimbusRecipe) =>
- recipe.getRecipeInfo(),
- );
-
- const totalExperiments = msgExpRecipeCollection.recipes.length;
-
- const msgRolloutInfo: RecipeOrBranchInfo[] = isLookerEnabled
- ? // Update branches inside recipe infos with CTR percents
- await msgRolloutRecipeCollection.getExperimentAndBranchInfos()
- : msgRolloutRecipeCollection.recipes.map((recipe: NimbusRecipe) =>
- recipe.getRecipeInfo(),
- );
-
- const totalRolloutExperiments = msgRolloutRecipeCollection.recipes.length;
+export default async function Page() {
+ const {
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+ } = await fetchData(platform);
return (
-
-
-
Skylight
-
-
-
-
- Desktop Messages Released on Firefox
-
-
-
-
-
-
-
-
-
-
-
- Current Desktop Message Rollouts
-
-
- Total: {totalRolloutExperiments}
-
-
-
-
-
-
-
-
-
- Current Desktop Message Experiments
-
-
- Total: {totalExperiments}
-
-
-
-
-
-
-
-
+
);
}
diff --git a/app/types.ts b/app/types.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/experimentUtils.ts b/lib/experimentUtils.ts
index a73e248e..e1f4df89 100644
--- a/lib/experimentUtils.ts
+++ b/lib/experimentUtils.ts
@@ -34,6 +34,13 @@ export const MESSAGING_EXPERIMENTS_DEFAULT_FEATURES: string[] = [
"spotlight",
"testFeature",
"whatsNewPage",
+
+ // XXX these should live elsewhere; they are Android features
+ "cfr",
+ "encourage-search-cfr",
+ "messaging",
+ "juno-onboarding",
+ "set-to-default-prompt",
];
/**
diff --git a/lib/messageUtils.ts b/lib/messageUtils.ts
index 4c0bcbbe..97698b0f 100644
--- a/lib/messageUtils.ts
+++ b/lib/messageUtils.ts
@@ -113,6 +113,62 @@ export function _isAboutWelcomeTemplate(template: string): boolean {
return aboutWelcomeSurfaces.includes(template);
}
+//mozilla.cloud.looker.com/dashboards/2191?Normalized+Channel=release&Submission+Date=2025%2F02%2F13+to+2025%2F03%2F13&Experiment+Slug=rootca-info-card-hcr1-fenix&Value+Branch=treatment-a&Sample+ID=%3C%3D10&Value=info%5E_card%5E_rootCA%5E_HCR1%25
+
+export function getAndroidDashboard(
+ surface: string,
+ msgIdPrefix: string,
+ channel?: string,
+ experiment?: string,
+ branchSlug?: string,
+ startDate?: string | null,
+ endDate?: string | null,
+ isCompleted?: boolean,
+): string | undefined {
+ // The isCompleted value can be useful for messages that used to be in remote
+ // settings or old versions of Firefox.
+ const submissionDate = getLookerSubmissionTimestampDateFilter(
+ startDate,
+ endDate,
+ isCompleted,
+ );
+
+ const dashboardId = 2191; // messages/push notification
+ // XXXgetDashboardIdForTemplate(surface);
+ let baseUrl = `https://mozilla.cloud.looker.com/dashboards/${dashboardId}`;
+ let paramObj;
+
+ paramObj = {
+ "Submission Date": submissionDate,
+ //"Messaging System Message Id": msgIdPrefix,
+ "Normalized Channel": channel ? channel : "",
+ "Normalized OS": "",
+ "Client Info App Display Version": "",
+ "Normalized Country Code": "",
+ "Experiment Slug": experiment ? experiment : "", // XXX
+ "Experiment Branch": branchSlug ? branchSlug : "",
+ // XXX assumes last part of message id is something like
+ // "-en-us" and chops that off, since we want to know about
+ // all the messages in the experiment. Will break
+ // (in "no results" way) on experiment with messages not configured
+ // like that.
+
+ Value: msgIdPrefix.slice(0, -5) + "%", // XXX
+ };
+
+ // XXX we really handle all messaging surfaces, at least in theory
+ if (surface !== "survey") return undefined;
+
+ if (paramObj) {
+ const params = new URLSearchParams(Object.entries(paramObj));
+ let url = new URL(baseUrl);
+ url.search = params.toString();
+ return url.toString();
+ }
+
+ return undefined;
+}
+
export function getDashboard(
template: string,
msgId: string,
diff --git a/lib/nimbusRecipe.ts b/lib/nimbusRecipe.ts
index 60cf1d52..53cd54e0 100644
--- a/lib/nimbusRecipe.ts
+++ b/lib/nimbusRecipe.ts
@@ -1,6 +1,7 @@
import { types } from "@mozilla/nimbus-shared";
import { BranchInfo, RecipeInfo, RecipeOrBranchInfo } from "../app/columns.jsx";
import {
+ getAndroidDashboard,
getDashboard,
getSurfaceDataForTemplate,
getPreviewLink,
@@ -72,10 +73,107 @@ export class NimbusRecipe implements NimbusRecipeType {
this._isCompleted = isCompleted;
}
- /**
- * @returns an array of BranchInfo objects, one per branch in this recipe
- */
+ getAndroidBranchInfo(branch: any): BranchInfo {
+ let branchInfo: BranchInfo = {
+ product: "Android",
+ id: branch.slug,
+ isBranch: true,
+ // The raw experiment data can be automatically serialized to
+ // the client by NextJS (but classes can't), and any
+ // needed NimbusRecipe class rewrapping can be done there.
+ nimbusExperiment: this._rawRecipe,
+ slug: branch.slug,
+ screenshots: branch.screenshots,
+ description: branch.description,
+ };
+
+ // XXX need to handle multi branches
+ const feature = branch.features[0];
+
+ switch (feature.featureId) {
+ case "messaging":
+ // console.log("in messaging feature, feature = ", feature);
+
+ // console.log("feature.value = ", feature.value);
+ if (Object.keys(feature.value).length === 0) {
+ console.warn(
+ "empty feature value, returning error, branch.slug = ",
+ branch.slug,
+ );
+ return branchInfo;
+ }
+
+ const message0: any = Object.values(feature.value.messages)[0];
+ const message0Id: string = Object.keys(feature.value.messages)[0];
+ branchInfo.id = message0Id;
+
+ // console.log("message0 = ", message0);
+
+ const surface = message0.surface;
+ // XXX need to rename template & surface somehow
+ branchInfo.template = surface;
+ branchInfo.surface = surface;
+
+ switch (surface) {
+ case "messages":
+ // XXX I don' tthink this a real case
+ console.log("in messages surface case");
+ break;
+
+ case "survey":
+ break;
+
+ default:
+ console.warn("unhandled message surface: ", branchInfo.surface);
+ }
+ break;
+
+ case "juno-onboarding":
+ console.warn(`we don't fully support juno-onboarding messages yet`);
+ break;
+
+ default:
+ console.warn("default hit");
+ console.warn("branch.slug = ", branch.slug);
+ console.warn("We don't support feature = ", feature);
+ // JSON.stringify(branch.features),
+ // );
+ }
+
+ const proposedEndDate = getExperimentLookerDashboardDate(
+ branchInfo.nimbusExperiment.startDate,
+ branchInfo.nimbusExperiment.proposedDuration,
+ );
+ let formattedEndDate;
+ if (branchInfo.nimbusExperiment.endDate) {
+ formattedEndDate = formatDate(branchInfo.nimbusExperiment.endDate, 1);
+ }
+
+ branchInfo.ctrDashboardLink = getAndroidDashboard(
+ branchInfo.surface as string,
+ branchInfo.id,
+ undefined,
+ branchInfo.nimbusExperiment.slug,
+ branch.slug,
+ branchInfo.nimbusExperiment.startDate,
+ branchInfo.nimbusExperiment.endDate ? formattedEndDate : proposedEndDate,
+ this._isCompleted,
+ );
+
+ console.log("Android Dashboard:", branchInfo.ctrDashboardLink);
+
+ return branchInfo;
+ }
getBranchInfo(branch: any): BranchInfo {
+ switch (this._rawRecipe.appName) {
+ case "fenix":
+ return this.getAndroidBranchInfo(branch);
+ default:
+ return this.getDesktopBranchInfo(branch);
+ }
+ }
+
+ getDesktopBranchInfo(branch: any): BranchInfo {
let branchInfo: BranchInfo = {
product: "Desktop",
id: branch.slug,
@@ -240,6 +338,7 @@ export class NimbusRecipe implements NimbusRecipeType {
return branchInfo;
default:
+ //console.log("Hit default case, template = ", template);
if (!feature.value?.messages) {
// console.log("v.messages is null");
// console.log(", feature.value = ", feature.value);
diff --git a/lib/nimbusRecipeCollection.ts b/lib/nimbusRecipeCollection.ts
index 9044175a..dc0f0a34 100644
--- a/lib/nimbusRecipeCollection.ts
+++ b/lib/nimbusRecipeCollection.ts
@@ -3,6 +3,7 @@ import { NimbusRecipe } from "../lib/nimbusRecipe";
import { BranchInfo, RecipeInfo, RecipeOrBranchInfo } from "@/app/columns";
import { getCTRPercentData } from "./looker";
import { getExperimentLookerDashboardDate } from "./lookerUtils";
+import { Platform } from "./types";
type NimbusExperiment = types.experiments.NimbusExperiment;
@@ -10,6 +11,7 @@ type NimbusRecipeCollectionType = {
recipes: Array;
isCompleted: boolean;
fetchRecipes: () => Promise>;
+ platform: Platform;
};
/**
@@ -47,16 +49,25 @@ async function updateBranchesCTR(recipe: NimbusRecipe): Promise {
export class NimbusRecipeCollection implements NimbusRecipeCollectionType {
recipes: Array;
isCompleted: boolean;
+ platform: Platform;
- constructor(isCompleted: boolean = false) {
+ // XXX XXX remove this default platform, it's a total footgun
+ constructor(
+ isCompleted: boolean = false,
+ platform: Platform = "firefox-desktop",
+ ) {
this.recipes = [];
this.isCompleted = isCompleted;
+ this.platform = platform;
}
async fetchRecipes(): Promise> {
- let experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}${process.env.EXPERIMENTER_API_CALL_LIVE}`;
+ // XXX should really be using URL.parse and URLSearchParams to manage all
+ // this stuff
+ let experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}?status=Live&application=${this.platform}`;
if (this.isCompleted) {
- experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}${process.env.EXPERIMENTER_API_CALL_COMPLETED}`;
+ // XXX rename to isComplete for consistency
+ experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}?status=Complete&application=${this.platform}`;
}
// console.log("experimenterURL = ", experimenterUrl)
diff --git a/lib/platformInfo.ts b/lib/platformInfo.ts
new file mode 100644
index 00000000..9b19699a
--- /dev/null
+++ b/lib/platformInfo.ts
@@ -0,0 +1,16 @@
+import { Platform } from "./types";
+interface PlatformInfo {
+ displayName: string;
+}
+
+export const platformDictionary: Record = {
+ fenix: {
+ displayName: "Android",
+ },
+ ios: {
+ displayName: "iOS",
+ },
+ "firefox-desktop": {
+ displayName: "Desktop",
+ },
+};
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 00000000..68681e99
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1,5 @@
+// These are the same strings that the experimenter API uses to determine which
+// endpoint to hit. XXX we should use our own ID and put this in
+// PlatformInfo in case Nimbus changes its strings.
+
+export type Platform = "fenix" | "ios" | "firefox-desktop";