Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 36x 36x 36x 1x 1x 25x 25x 100x 25x 25x 25x 25x 25x 25x 25x 25x 25x 1x 1x 1x 36x 36x 36x 36x 36x 1x 1x 1x 12x 12x 2x 2x 2x 2x 1x 1x 1x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 2x 2x 2x 2x 2x | /**
* Persists the small bookkeeping record behind the "leave a review" prompt:
* when the extension was installed, how many searches the user has run, how many
* products those searches returned, and the prompt's dismissal state.
*
* Stored as a single object under {@link CACHE.REVIEW_PROMPT} in
* `chrome.storage.local` (via {@link cstorage}). Every mutation is a
* read-modify-write of that one record; reads normalize partial or legacy shapes
* so callers always receive a fully-populated {@link ReviewPromptState}.
* @module reviewStats
* @category Utils
* @group Stats
* @source
*/
import { CACHE } from '@/constants/common';
import { cstorage } from '@/utils/storage';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
const logger = new Logger('reviewStats');
/** Milliseconds in a day, for install-age and snooze math. */
const DAY_MS = 24 * 60 * 60 * 1000;
/** How long the first dismissal snoozes the prompt before its final showing. */
export const REVIEW_SNOOZE_MS = 30 * DAY_MS;
/**
* The review-prompt record persisted under {@link CACHE.REVIEW_PROMPT}.
* @category Utils
* @group Stats
*/
export interface ReviewPromptState {
/** Epoch ms the extension was installed (or first seen, for pre-existing users). */
installedAt: number;
/** Lifetime count of user-initiated searches. */
searchCount: number;
/** Lifetime count of products returned across all searches. */
totalResults: number;
/** Times the prompt has been dismissed: 0 (never), 1 (snoozed), 2 (silenced). */
dismissCount: number;
/** Epoch ms before which the prompt stays hidden after the first dismissal. */
snoozedUntil?: number;
/** True once the user has opened the reviews page — the prompt never returns. */
reviewed?: boolean;
}
/** A fresh record: no install date yet, all counters zeroed. */
const DEFAULT_STATE: ReviewPromptState = {
installedAt: 0,
searchCount: 0,
totalResults: 0,
dismissCount: 0,
};
/**
* Narrows an unknown storage value to a plain object for field extraction.
* @param value - Raw value read from storage.
* @returns True when the value is a non-null object.
* @source
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/**
* Coerces a raw storage value into a complete {@link ReviewPromptState},
* defaulting any missing or malformed field. Tolerates partial records so a
* later-added field never throws on an older stored shape.
* @param raw - The object read from storage.
* @returns A fully-populated review-prompt state.
* @source
*/
function normalize(raw: Record<string, unknown>): ReviewPromptState {
const num = (value: unknown, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
return {
installedAt: num(raw.installedAt, 0),
searchCount: num(raw.searchCount, 0),
totalResults: num(raw.totalResults, 0),
dismissCount: num(raw.dismissCount, 0),
snoozedUntil: typeof raw.snoozedUntil === 'number' ? raw.snoozedUntil : undefined,
reviewed: raw.reviewed === true ? true : undefined,
};
}
/**
* Reads the current review-prompt state, normalized. Returns a zeroed default
* when nothing has been stored yet.
* @returns The persisted {@link ReviewPromptState}.
* @example
* ```typescript
* const state = await getReviewPromptState();
* state.searchCount; // 0 on a fresh profile
* ```
* @category Utils
* @group Stats
* @source
*/
export async function getReviewPromptState(): Promise<ReviewPromptState> {
try {
const stored = (await cstorage.local.get([CACHE.REVIEW_PROMPT]))[CACHE.REVIEW_PROMPT];
return isRecord(stored) ? normalize(stored) : { ...DEFAULT_STATE };
} catch (error) {
logger.error(`Failed to read review-prompt state: ${getErrorMessage(error)}`, { error });
return { ...DEFAULT_STATE };
}
}
/**
* Records the install date the first time it's seen. New installs get an exact
* date from the service worker; pre-existing users are backfilled with "now" on
* their first open after this ships. A no-op once `installedAt` is set.
* @returns Resolves once the date has been backfilled (or was already present).
* @example
* ```typescript
* await ensureInstallDate(); // sets installedAt on a profile that had none
* ```
* @category Utils
* @group Stats
* @source
*/
export async function ensureInstallDate(): Promise<void> {
const state = await getReviewPromptState();
if (state.installedAt > 0) return;
await cstorage.local.set({
[CACHE.REVIEW_PROMPT]: { ...state, installedAt: Date.now() },
});
}
/**
* Tallies one completed search and the products it returned. Also backfills the
* install date, so counting starts even if the record was never seeded.
* @param resultCount - The number of products this search produced.
* @returns Resolves once the updated counters have been persisted.
* @example
* ```typescript
* await recordSearch(42); // searchCount += 1, totalResults += 42
* ```
* @category Utils
* @group Stats
* @source
*/
export async function recordSearch(resultCount: number): Promise<void> {
const state = await getReviewPromptState();
const added = Number.isFinite(resultCount) && resultCount > 0 ? resultCount : 0;
await cstorage.local.set({
[CACHE.REVIEW_PROMPT]: {
...state,
installedAt: state.installedAt > 0 ? state.installedAt : Date.now(),
searchCount: state.searchCount + 1,
totalResults: state.totalResults + added,
},
});
}
/**
* Records a dismissal. The first ✕ snoozes the prompt for {@link REVIEW_SNOOZE_MS}
* so it can show one final time; any later dismissal just advances the counter,
* which silences it for good.
* @returns Resolves once the dismissal has been persisted.
* @example
* ```typescript
* await snoozeReviewPrompt(); // dismissCount 0 -> 1, sets snoozedUntil
* ```
* @category Utils
* @group Stats
* @source
*/
export async function snoozeReviewPrompt(): Promise<void> {
const state = await getReviewPromptState();
const dismissCount = state.dismissCount + 1;
await cstorage.local.set({
[CACHE.REVIEW_PROMPT]: {
...state,
dismissCount,
snoozedUntil: dismissCount === 1 ? Date.now() + REVIEW_SNOOZE_MS : state.snoozedUntil,
},
});
}
/**
* Marks the prompt as answered once the user opens the reviews page, so it never
* appears again.
* @returns Resolves once the reviewed flag has been persisted.
* @example
* ```typescript
* await markReviewed(); // sets reviewed: true
* ```
* @category Utils
* @group Stats
* @source
*/
export async function markReviewed(): Promise<void> {
const state = await getReviewPromptState();
await cstorage.local.set({
[CACHE.REVIEW_PROMPT]: { ...state, reviewed: true },
});
}
|