diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 0c892acafc..3e33c53586 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -38,6 +38,7 @@ export default function InboxScreen() { ); const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); const statusFilter = useInboxFilterStore((s) => s.statusFilter); + const priorityFilter = useInboxFilterStore((s) => s.priorityFilter); const suggestedReviewerFilter = useInboxFilterStore( (s) => s.suggestedReviewerFilter, ); @@ -66,6 +67,7 @@ export default function InboxScreen() { sourceProductFilter, statusFilter, suggestedReviewerFilter, + priorityFilter, defaultStatusFilter: DEFAULT_STATUS_FILTER, }), ); @@ -78,6 +80,7 @@ export default function InboxScreen() { sourceProductFilter, statusFilter, suggestedReviewerFilter, + priorityFilter, ]); // ── Tinder mode data ────────────────────────────────────────────────────── diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index 6c6bd190e5..db72f0d163 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -45,6 +45,9 @@ export async function getSignalReports( if (params?.suggested_reviewers) { url.searchParams.set("suggested_reviewers", params.suggested_reviewers); } + if (params?.priority) { + url.searchParams.set("priority", params.priority); + } const response = await authedFetch(url.toString()); diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx index 78492d82a5..95dfddcc55 100644 --- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx @@ -7,7 +7,7 @@ import { type SourceProduct, useInboxFilterStore, } from "../stores/inboxFilterStore"; -import type { SignalReportStatus } from "../types"; +import type { SignalReportPriority, SignalReportStatus } from "../types"; import { inboxStatusLabel } from "../utils"; interface FilterSheetProps { @@ -49,6 +49,25 @@ function useStatusDotColors(): Record { }; } +const FILTERABLE_PRIORITIES: SignalReportPriority[] = [ + "P0", + "P1", + "P2", + "P3", + "P4", +]; + +function usePriorityDotColors(): Record { + const themeColors = useThemeColors(); + return { + P0: themeColors.status.error, + P1: themeColors.status.warning, + P2: themeColors.status.warning, + P3: themeColors.gray[9], + P4: themeColors.gray[9], + }; +} + const SOURCE_PRODUCT_OPTIONS: { value: SourceProduct; label: string }[] = [ { value: "session_replay", label: "Session replay" }, { value: "error_tracking", label: "Error tracking" }, @@ -97,6 +116,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { const { bottom, sheetContentTop } = useScreenInsets(); const themeColors = useThemeColors(); const statusDotColors = useStatusDotColors(); + const priorityDotColors = usePriorityDotColors(); const sortField = useInboxFilterStore((s) => s.sortField); const sortDirection = useInboxFilterStore((s) => s.sortDirection); @@ -105,10 +125,13 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { const toggleStatus = useInboxFilterStore((s) => s.toggleStatus); const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); const toggleSourceProduct = useInboxFilterStore((s) => s.toggleSourceProduct); + const priorityFilter = useInboxFilterStore((s) => s.priorityFilter); + const togglePriority = useInboxFilterStore((s) => s.togglePriority); const resetFilters = useInboxFilterStore((s) => s.resetFilters); const hasActiveFilters = sourceProductFilter.length > 0 || + priorityFilter.length > 0 || statusFilter.length < FILTERABLE_STATUSES.length; return ( @@ -186,6 +209,25 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { ))} + {/* Priority */} + + + {FILTERABLE_PRIORITIES.map((priority) => ( + togglePriority(priority)} + left={ + + } + /> + ))} + + {/* Source */} diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index 1394ee4eed..785d112d6e 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -25,6 +25,7 @@ import type { SuggestedReviewerWriteEntry, } from "../types"; import { + buildPriorityFilterParam, buildSignalReportListOrdering, buildStatusFilterParam, buildSuggestedReviewerFilterParam, @@ -51,6 +52,7 @@ export function useInboxReports(options?: { enabled?: boolean }) { const suggestedReviewerFilter = useInboxFilterStore( (s) => s.suggestedReviewerFilter, ); + const priorityFilter = useInboxFilterStore((s) => s.priorityFilter); const params: SignalReportsQueryParams = { status: buildStatusFilterParam(statusFilter), @@ -63,6 +65,7 @@ export function useInboxReports(options?: { enabled?: boolean }) { suggestedReviewerFilter.length > 0 ? buildSuggestedReviewerFilterParam(suggestedReviewerFilter) : undefined, + priority: buildPriorityFilterParam(priorityFilter), }; const query = useQuery({ diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts new file mode 100644 index 0000000000..31cae025d2 --- /dev/null +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useInboxFilterStore } from "./inboxFilterStore"; + +const INITIAL_STATE = useInboxFilterStore.getState(); + +beforeEach(() => { + useInboxFilterStore.setState(INITIAL_STATE, true); +}); + +describe("inboxFilterStore priority filter", () => { + it("starts empty (no priority filter)", () => { + expect(useInboxFilterStore.getState().priorityFilter).toEqual([]); + }); + + it("toggles a priority on and off", () => { + const { togglePriority } = useInboxFilterStore.getState(); + + togglePriority("P0"); + expect(useInboxFilterStore.getState().priorityFilter).toEqual(["P0"]); + + togglePriority("P0"); + expect(useInboxFilterStore.getState().priorityFilter).toEqual([]); + }); + + it("accumulates multiple priorities", () => { + const { togglePriority } = useInboxFilterStore.getState(); + + togglePriority("P0"); + togglePriority("P2"); + expect(useInboxFilterStore.getState().priorityFilter).toEqual(["P0", "P2"]); + }); + + it("dedupes when set directly", () => { + useInboxFilterStore.getState().setPriorityFilter(["P1", "P1", "P3"]); + expect(useInboxFilterStore.getState().priorityFilter).toEqual(["P1", "P3"]); + }); + + it("clears the priority filter on reset", () => { + useInboxFilterStore.getState().setPriorityFilter(["P0", "P1"]); + useInboxFilterStore.getState().resetFilters(); + expect(useInboxFilterStore.getState().priorityFilter).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index 0f2f3391e1..e777738bfa 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,7 +1,11 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { SignalReportOrderingField, SignalReportStatus } from "../types"; +import type { + SignalReportOrderingField, + SignalReportPriority, + SignalReportStatus, +} from "../types"; type SortField = Extract< SignalReportOrderingField, @@ -34,6 +38,7 @@ interface InboxFilterState { statusFilter: SignalReportStatus[]; sourceProductFilter: SourceProduct[]; suggestedReviewerFilter: string[]; + priorityFilter: SignalReportPriority[]; } interface InboxFilterActions { @@ -43,6 +48,8 @@ interface InboxFilterActions { toggleSourceProduct: (source: SourceProduct) => void; toggleSuggestedReviewer: (reviewerUuid: string) => void; setSuggestedReviewerFilter: (reviewerUuids: string[]) => void; + togglePriority: (priority: SignalReportPriority) => void; + setPriorityFilter: (priorities: SignalReportPriority[]) => void; resetFilters: () => void; } @@ -56,6 +63,7 @@ export const useInboxFilterStore = create()( statusFilter: DEFAULT_STATUS_FILTER, sourceProductFilter: [], suggestedReviewerFilter: [], + priorityFilter: [], setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), setStatusFilter: (statusFilter) => set({ statusFilter }), @@ -88,11 +96,22 @@ export const useInboxFilterStore = create()( set({ suggestedReviewerFilter: Array.from(new Set(reviewerUuids)), }), + togglePriority: (priority) => + set((state) => { + const current = state.priorityFilter; + const next = current.includes(priority) + ? current.filter((p) => p !== priority) + : [...current, priority]; + return { priorityFilter: next }; + }), + setPriorityFilter: (priorities) => + set({ priorityFilter: Array.from(new Set(priorities)) }), resetFilters: () => set({ statusFilter: DEFAULT_STATUS_FILTER, sourceProductFilter: [], suggestedReviewerFilter: [], + priorityFilter: [], }), }), { @@ -104,6 +123,7 @@ export const useInboxFilterStore = create()( statusFilter: state.statusFilter, sourceProductFilter: state.sourceProductFilter, suggestedReviewerFilter: state.suggestedReviewerFilter, + priorityFilter: state.priorityFilter, }), }, ), diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts index cef53a7d7d..04afb0bff6 100644 --- a/apps/mobile/src/features/inbox/types.ts +++ b/apps/mobile/src/features/inbox/types.ts @@ -53,6 +53,7 @@ export interface SignalReportsQueryParams { ordering?: string; source_product?: string; suggested_reviewers?: string; + priority?: string; } export interface SignalProcessingStateResponse { diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index 9663998ec6..d87bbdbf99 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -7,6 +7,7 @@ import type { } from "./types"; import { buildInboxViewedProperties, + buildPriorityFilterParam, buildReviewerOptions, reviewerMatchesAvailable, toSuggestedReviewerWriteContent, @@ -67,6 +68,7 @@ describe("buildInboxViewedProperties", () => { sourceProductFilter: [], statusFilter: DEFAULT_STATUS_FILTER, suggestedReviewerFilter: [], + priorityFilter: [], defaultStatusFilter: DEFAULT_STATUS_FILTER, }); expect(props).toMatchObject({ @@ -116,6 +118,7 @@ describe("buildInboxViewedProperties", () => { sourceProductFilter: [], statusFilter: DEFAULT_STATUS_FILTER, suggestedReviewerFilter: [], + priorityFilter: [], defaultStatusFilter: DEFAULT_STATUS_FILTER, }); @@ -131,11 +134,12 @@ describe("buildInboxViewedProperties", () => { expect(props.actionability_unknown_count).toBe(1); }); - it("marks filters active when any of status/source/reviewer differs from defaults", () => { + it("marks filters active when any of status/source/reviewer/priority differs from defaults", () => { const narrowed = buildInboxViewedProperties([], 0, { sourceProductFilter: [], statusFilter: ["ready"], suggestedReviewerFilter: [], + priorityFilter: [], defaultStatusFilter: DEFAULT_STATUS_FILTER, }); expect(narrowed.has_active_filters).toBe(true); @@ -145,6 +149,7 @@ describe("buildInboxViewedProperties", () => { sourceProductFilter: ["error_tracking"], statusFilter: DEFAULT_STATUS_FILTER, suggestedReviewerFilter: [], + priorityFilter: [], defaultStatusFilter: DEFAULT_STATUS_FILTER, }); expect(sourced.has_active_filters).toBe(true); @@ -154,9 +159,19 @@ describe("buildInboxViewedProperties", () => { sourceProductFilter: [], statusFilter: DEFAULT_STATUS_FILTER, suggestedReviewerFilter: ["uuid-1"], + priorityFilter: [], defaultStatusFilter: DEFAULT_STATUS_FILTER, }); expect(reviewer.has_active_filters).toBe(true); + + const prioritized = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + priorityFilter: ["P0"], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(prioritized.has_active_filters).toBe(true); }); it("treats a reordered default status set as not filtered", () => { @@ -164,6 +179,7 @@ describe("buildInboxViewedProperties", () => { sourceProductFilter: [], statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), suggestedReviewerFilter: [], + priorityFilter: [], defaultStatusFilter: DEFAULT_STATUS_FILTER, }); expect(props.has_active_filters).toBe(false); @@ -229,6 +245,28 @@ describe("reviewerMatchesAvailable", () => { }); }); +describe("buildPriorityFilterParam", () => { + it.each([ + { + name: "returns undefined for an empty selection", + input: [], + expected: undefined, + }, + { + name: "joins selected priorities with commas", + input: ["P0", "P2"] as const, + expected: "P0,P2", + }, + { + name: "dedupes repeated priorities", + input: ["P1", "P1", "P3"] as const, + expected: "P1,P3", + }, + ])("$name", ({ input, expected }) => { + expect(buildPriorityFilterParam([...input])).toBe(expected); + }); +}); + describe("buildReviewerOptions", () => { it("dedupes by uuid and pins the current user first", () => { const options = buildReviewerOptions( diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index cfcd8aa373..f80935f70f 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -3,6 +3,7 @@ import type { AvailableSuggestedReviewer, SignalReport, SignalReportOrderingField, + SignalReportPriority, SignalReportStatus, SuggestedReviewer, SuggestedReviewerWriteEntry, @@ -63,6 +64,13 @@ export function buildSuggestedReviewerFilterParam( return Array.from(new Set(normalized)).join(","); } +export function buildPriorityFilterParam( + priorities: SignalReportPriority[], +): string | undefined { + if (priorities.length === 0) return undefined; + return Array.from(new Set(priorities)).join(","); +} + export function filterReportsBySearch( reports: SignalReport[], query: string, @@ -171,6 +179,7 @@ interface InboxViewedFilterState { sourceProductFilter: string[]; statusFilter: SignalReportStatus[]; suggestedReviewerFilter: string[]; + priorityFilter: SignalReportPriority[]; /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ defaultStatusFilter: SignalReportStatus[]; } @@ -227,7 +236,8 @@ export function buildInboxViewedProperties( const hasActiveFilters = statusFiltered || filters.sourceProductFilter.length > 0 || - filters.suggestedReviewerFilter.length > 0; + filters.suggestedReviewerFilter.length > 0 || + filters.priorityFilter.length > 0; return { report_count: reports.length,