diff --git a/src/app/manifest.ts b/src/app/manifest.ts index e2e7135..451a9f4 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -18,8 +18,8 @@ export default function manifest(): MetadataRoute.Manifest { 'Footfall, campaign and LYT redemption analytics for Loyaly merchants.', start_url: '/dashboard', display: 'standalone', - background_color: '#000000', - theme_color: '#000000', + background_color: '#F7F7F8', + theme_color: '#F7F7F8', icons: [ {src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'}, {src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any'}, diff --git a/src/features/commerce/hooks/useSales.ts b/src/features/commerce/hooks/useSales.ts index a1de7da..1af8726 100644 --- a/src/features/commerce/hooks/useSales.ts +++ b/src/features/commerce/hooks/useSales.ts @@ -5,9 +5,26 @@ import {useResource} from '@/shared/hooks/useResource'; import {useScope} from '@/shared/hooks/useScope'; import type {Resource} from '@/shared/hooks/useResource'; import type {Sale} from '@/features/commerce/types/sale'; +import {useWorkspace} from '@/shared/providers/WorkspaceProvider'; +import {useSampleFallback} from '@/shared/mocks/withSample'; +import {buildSampleSales} from '@/shared/mocks/sampleData'; + +/** The workspace's real store ids a sample bill can belong to. */ +function useSampleSites(storeId: string): string[] { + const {stores} = useWorkspace(); + return storeId === 'all' ? stores.filter((s) => s.id !== 'all').map((s) => s.id) : [storeId]; +} export function useSales(): Resource { - return useResource(saleRepository.list(useScope())); + const scope = useScope(); + const {storeId, range} = scope; + const sites = useSampleSites(storeId); + // SAMPLE while no sale is recorded for the scope — see useSampleFallback. + return useSampleFallback( + useResource(saleRepository.list(scope)), + (now) => buildSampleSales(range, storeId, sites, now), + [range, storeId, sites], + ); } /** @@ -18,7 +35,13 @@ export function useSales(): Resource { */ export function useTodaySales(enabled = true): Resource { const {storeId} = useScope(); - return useResource( + const sites = useSampleSites(storeId); + const real = useResource( enabled ? saleRepository.list({storeId, range: 'today'}) : null, ); + return useSampleFallback( + real, + (now) => buildSampleSales('today', storeId, sites, now), + ['today', storeId, sites], + ); } diff --git a/src/features/dashboard/components/KpiRow.tsx b/src/features/dashboard/components/KpiRow.tsx index d29a54c..916748a 100644 --- a/src/features/dashboard/components/KpiRow.tsx +++ b/src/features/dashboard/components/KpiRow.tsx @@ -1,6 +1,8 @@ 'use client'; import {Grid} from '@astryxdesign/core/Grid'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {SampleTag} from '@/shared/mocks/SampleTag'; import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; import {MetricCard} from '@/shared/components/patterns/MetricCard'; import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState'; @@ -53,21 +55,28 @@ export function KpiRow({resource}: {resource: Resource}) { loading={} > {(kpis) => ( - - {kpis.map((k) => ( - - ))} - + + {resource.isSample ? ( + + + + ) : null} + + {kpis.map((k) => ( + + ))} + + )} ); diff --git a/src/features/dashboard/hooks/useDashboard.ts b/src/features/dashboard/hooks/useDashboard.ts index f426662..f7c1f2e 100644 --- a/src/features/dashboard/hooks/useDashboard.ts +++ b/src/features/dashboard/hooks/useDashboard.ts @@ -66,6 +66,7 @@ export function useDashboardKpis( // The server's clock, from whichever response landed — every relative time // on this page measures against it rather than the device. const meta = footfall.meta ?? conversion.meta; + const isSample = footfall.isSample || conversion.isSample; if (footfall.status === 'error' || conversion.status === 'error') { return { @@ -97,6 +98,7 @@ export function useDashboardKpis( refetch, isRefreshing, meta, + isSample, } as Resource; }, [footfall, conversion]); } diff --git a/src/features/dashboard/hooks/useReports.ts b/src/features/dashboard/hooks/useReports.ts index bcace82..c1d0a15 100644 --- a/src/features/dashboard/hooks/useReports.ts +++ b/src/features/dashboard/hooks/useReports.ts @@ -9,6 +9,9 @@ import type { FootfallReport, } from '@/features/dashboard/types/reports'; import type {VisitsPage} from '@/features/dashboard/types/visits'; +import {reportHasSignal} from '@/shared/hooks/resourceUtils'; +import {useSampleFallback} from '@/shared/mocks/withSample'; +import {buildSampleConversion, buildSampleFootfall} from '@/shared/mocks/sampleData'; /** * One hook per resource, scoped by the workspace's {site, range}. @@ -20,13 +23,29 @@ import type {VisitsPage} from '@/features/dashboard/types/visits'; export function useFootfallReport( opts: {bucket?: string; compare?: boolean} = {}, ): Resource { - return useResource(reportRepository.footfall(useScope(), opts)); + const scope = useScope(); + const {storeId, range} = scope; + // SAMPLE while the report has no visitors — see useSampleFallback. + return useSampleFallback( + useResource(reportRepository.footfall(scope, opts)), + (now) => buildSampleFootfall(range, storeId, now, opts.bucket), + [range, storeId, opts.bucket], + (d) => reportHasSignal(d, 'visitors'), + ); } export function useConversionReport( opts: {bucket?: string; compare?: boolean} = {}, ): Resource { - return useResource(reportRepository.conversion(useScope(), opts)); + const scope = useScope(); + const {storeId, range} = scope; + // SAMPLE while the report has no purchases — see useSampleFallback. + return useSampleFallback( + useResource(reportRepository.conversion(scope, opts)), + (now) => buildSampleConversion(range, storeId, now, opts.bucket), + [range, storeId, opts.bucket], + (d) => reportHasSignal(d, 'purchases') || reportHasSignal(d, 'revenue'), + ); } export function useRecentVisits(limit = 6): Resource { diff --git a/src/features/engagement/components/ActivitiesPanel.tsx b/src/features/engagement/components/ActivitiesPanel.tsx index 56bf925..867bb2e 100644 --- a/src/features/engagement/components/ActivitiesPanel.tsx +++ b/src/features/engagement/components/ActivitiesPanel.tsx @@ -34,7 +34,9 @@ export function ActivitiesPanel() { const [recording, setRecording] = useState(false); const canRecord = - activities.status === 'success' && activities.data.some((a) => a.isActive); + activities.status === 'success' && + !activities.isSample && + activities.data.some((a) => a.isActive); const columns: TableColumn[] = [ { diff --git a/src/features/engagement/hooks/useEngagement.ts b/src/features/engagement/hooks/useEngagement.ts index 70cea3f..d054b6d 100644 --- a/src/features/engagement/hooks/useEngagement.ts +++ b/src/features/engagement/hooks/useEngagement.ts @@ -9,18 +9,48 @@ import type { Campaign, Journey, } from '@/features/engagement/types/engagement'; +import {useSampleFallback} from '@/shared/mocks/withSample'; +import { + buildSampleActivityRows, + buildSampleCampaignRows, + buildSampleJourney, +} from '@/shared/mocks/sampleData'; + +// Each falls back to SAMPLE while the endpoint has nothing — see +// useSampleFallback. The sample carries `attribution: 'estimated'`, so the +// AttributionNote still shows. export function useActivities(): Resource { - return useResource(engagementRepository.activities(useScope())); + const scope = useScope(); + const {storeId, range} = scope; + return useSampleFallback( + useResource(engagementRepository.activities(scope)), + (now) => buildSampleActivityRows(range, storeId, now), + [range, storeId], + ); } export function useCampaigns(): Resource { - return useResource(engagementRepository.campaigns(useScope())); + const scope = useScope(); + const {storeId, range} = scope; + return useSampleFallback( + useResource(engagementRepository.campaigns(scope)), + (now) => buildSampleCampaignRows(range, storeId, now), + [range, storeId], + ); } /** Empty only when there are no stages at all; a stage of zero people is data. */ export function useJourney(): Resource { - return useResource(engagementRepository.journey(useScope()), { - isEmpty: (j) => j.stages.length === 0, - }); + const scope = useScope(); + const {storeId, range} = scope; + return useSampleFallback( + useResource(engagementRepository.journey(scope), { + isEmpty: (j) => j.stages.length === 0, + }), + (now) => buildSampleJourney(range, storeId, now), + [range, storeId], + // JourneyPanel treats a journey where nobody reached any stage as empty. + (j) => j.stages.some((s) => s.customers > 0), + ); } diff --git a/src/features/settings/components/PreferencesForm.tsx b/src/features/settings/components/PreferencesForm.tsx index ba4d165..6d6d3ec 100644 --- a/src/features/settings/components/PreferencesForm.tsx +++ b/src/features/settings/components/PreferencesForm.tsx @@ -92,8 +92,8 @@ export function PreferencesForm() { value={themeMode} onChange={(v) => setThemeMode(v as ThemeMode)} options={[ - {label: 'Dark Mode (Monochrome Premium)', value: 'dark'}, {label: 'Light Mode', value: 'light'}, + {label: 'Dark Mode (Monochrome Premium)', value: 'dark'}, {label: 'System OS Preference', value: 'system'}, ]} /> diff --git a/src/shared/components/patterns/PanelCard.tsx b/src/shared/components/patterns/PanelCard.tsx index 374b23b..e2505b4 100644 --- a/src/shared/components/patterns/PanelCard.tsx +++ b/src/shared/components/patterns/PanelCard.tsx @@ -46,6 +46,7 @@ export function PanelCard({ subtitle={subtitle} actions={actions} level={headingLevel} + isSample={resource.isSample} /> {children} diff --git a/src/shared/components/patterns/SectionHeader.tsx b/src/shared/components/patterns/SectionHeader.tsx index 59b2842..2a3e84a 100644 --- a/src/shared/components/patterns/SectionHeader.tsx +++ b/src/shared/components/patterns/SectionHeader.tsx @@ -22,6 +22,7 @@ export function SectionHeader({ subtitle, actions, level = 2, + isSample: isSampleData = false, }: { title: string; /** @@ -33,9 +34,12 @@ export function SectionHeader({ subtitle?: React.ReactNode; actions?: React.ReactNode; level?: 2 | 3 | 4 | 5; + /** The panel's resource is a sample standing in for an empty endpoint. */ + isSample?: boolean; }) { // Inside a , every panel says so — see shared/mocks. - const isSample = useInSampleScope(); + const inScope = useInSampleScope(); + const isSample = inScope || isSampleData; return ( @@ -52,7 +56,11 @@ export function SectionHeader({ {actions} diff --git a/src/shared/components/primitives/MetricDelta.tsx b/src/shared/components/primitives/MetricDelta.tsx index 8605b28..fc1f7fc 100644 --- a/src/shared/components/primitives/MetricDelta.tsx +++ b/src/shared/components/primitives/MetricDelta.tsx @@ -1,16 +1,24 @@ 'use client'; +import {HStack} from '@astryxdesign/core/Layout'; import {Icon} from '@astryxdesign/core/Icon'; +import {Text} from '@astryxdesign/core/Text'; import {ICONS} from '@/shared/utils/icons'; import {formatDelta} from '@/shared/utils/format'; /** - * Modern illuminated period-over-period change pill badge. + * Period-over-period change: an arrow and a signed percentage, in the + * semantic colour for whether the move is good. + * + * Inline, not a pill. The pill's padding and border pushed "+10.3%" past an + * 88px table column and it wrapped onto two lines; the colour and arrow carry + * the meaning on their own. `nowrap` keeps the figure on one line wherever + * it sits. */ export function MetricDelta({ value, isRiseGood = true, - size: _size = 'sm', + size = 'sm', }: { value: number; isRiseGood?: boolean; @@ -18,30 +26,20 @@ export function MetricDelta({ }) { const isFlat = Math.abs(value) < 0.05; const isGood = value > 0 === isRiseGood; - - if (isFlat) { - return ( - - - 0.0% - - ); - } - - const badgeClass = isGood - ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' - : 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20'; + const tone = isFlat ? 'text-secondary' : isGood ? 'text-success' : 'text-error'; return ( - - 0 ? ICONS.up : ICONS.down} - size="xsm" - color={isGood ? 'success' : 'error'} - /> - {formatDelta(value)} - + + {isFlat ? null : ( + 0 ? ICONS.up : ICONS.down} + size="xsm" + color={isGood ? 'success' : 'error'} + /> + )} + + {isFlat ? '0.0%' : formatDelta(value)} + + ); } diff --git a/src/shared/hooks/resourceUtils.ts b/src/shared/hooks/resourceUtils.ts index ab1ce4d..0ed503a 100644 --- a/src/shared/hooks/resourceUtils.ts +++ b/src/shared/hooks/resourceUtils.ts @@ -39,6 +39,7 @@ export function joinResources( }, isRefreshing: a.isRefreshing || b.isRefreshing, meta: a.meta ?? b.meta, + isSample: a.isSample || b.isSample, }; if (a.status === 'error') return {...common, status: 'error', data: undefined, error: a.error}; if (b.status === 'error') return {...common, status: 'error', data: undefined, error: b.error}; diff --git a/src/shared/hooks/useResource.ts b/src/shared/hooks/useResource.ts index 2f6bdba..bbdb192 100644 --- a/src/shared/hooks/useResource.ts +++ b/src/shared/hooks/useResource.ts @@ -35,6 +35,12 @@ export type Resource = AsyncState & { * wrong countdown to any user whose device clock is off. */ meta?: ApiMeta; + /** + * TEMPORARY — set by useSampleFallback() when the real endpoint had nothing + * and an example is on screen instead. PanelCard shows the "Sample data" + * token from it. Goes with shared/mocks. + */ + isSample?: boolean; }; /** What the fetch resolved to, tagged with the request it belongs to. */ diff --git a/src/shared/mocks/sampleData.ts b/src/shared/mocks/sampleData.ts index b61c695..fd163c0 100644 --- a/src/shared/mocks/sampleData.ts +++ b/src/shared/mocks/sampleData.ts @@ -57,6 +57,17 @@ import type {LoginAuditItem} from '@/features/settings/components/SecurityManage import type { RangeKey, } from '@/shared/types/api'; +import type { + ConversionReport, + FootfallReport, + ReportBucket, +} from '@/features/dashboard/types/reports'; +import type {Sale} from '@/features/commerce/types/sale'; +import type { + ActivityRow, + Campaign, + Journey, +} from '@/features/engagement/types/engagement'; // =========================================================================== // Seeded randomness @@ -1635,3 +1646,217 @@ export const SAMPLE_AI_EXPORT_ROWS = [ /** The assistant landing's store-pulse strip — no live telemetry feed yet. */ export const SAMPLE_AI_PULSE = {footfallDeltaPct: 18.4, syncPct: 99.8}; + +// =========================================================================== +// Fallbacks for REAL endpoints that came back empty +// +// Read through useSampleFallback() (withSample.ts): the real response always +// wins when it carries anything, and the panel wears the "Sample data" token +// whenever one of these is on screen. Shapes match the BFF payloads exactly, +// so the panels render them with no special case. +// =========================================================================== + +/** Daily buckets from the shared timeseries, so every sample chart agrees. */ +function sampleDayBuckets(range: RangeKey, storeId: string, endMs: number): ReportBucket[] { + return buildTimeseries(range, storeId, endMs).map((p, i) => { + const rng = createRng('report-bucket', storeId, range, p.t, i); + const newVisitors = Math.round(p.visitors * rng.float(0.28, 0.42)); + return { + label: p.t, + visitors: p.visitors, + newVisitors, + returningVisitors: p.visitors - newVisitors, + purchases: p.purchases, + revenue: p.revenue, + conversion: p.conversion, + }; + }); +} + +/** Days rolled into weeks, labelled by each week's first day. */ +function rollWeeks(days: ReportBucket[]): ReportBucket[] { + const weeks: ReportBucket[] = []; + days.forEach((d, i) => { + if (i % 7 === 0) weeks.push({...d}); + else { + const w = weeks[weeks.length - 1]; + w.visitors += d.visitors; + w.newVisitors += d.newVisitors; + w.returningVisitors += d.returningVisitors; + w.purchases += d.purchases; + w.revenue += d.revenue; + } + }); + return weeks.map((w) => ({ + ...w, + conversion: w.visitors ? Number(((w.purchases / w.visitors) * 100).toFixed(1)) : 0, + })); +} + +function sampleBuckets(range: RangeKey, storeId: string, endMs: number, bucket?: string) { + const days = sampleDayBuckets(range, storeId, endMs); + return bucket === 'week' ? rollWeeks(days) : days; +} + +const previousEnd = (range: RangeKey, endMs: number) => endMs - RANGE_DAYS[range] * 86400000; + +export function buildSampleFootfall( + range: RangeKey, + storeId: string, + endMs: number, + bucket?: string, +): FootfallReport { + const buckets = sampleBuckets(range, storeId, endMs, bucket); + const visits = buckets.reduce((a, b) => a + b.visitors, 0); + const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0); + // Unique people, not bucket-visitors: regulars come more than once a window. + const total = Math.round(visits * 0.72); + const previousVisits = totalVisitors(range, storeId, previousEnd(range, endMs)); + return { + total, + newVisitors: Math.min(newVisitors, total), + returningVisitors: Math.max(0, total - newVisitors), + visits, + buckets, + timezone: 'Asia/Kolkata', + previousTotal: Math.round(previousVisits * 0.72), + }; +} + +export function buildSampleConversion( + range: RangeKey, + storeId: string, + endMs: number, + bucket?: string, +): ConversionReport { + const buckets = sampleBuckets(range, storeId, endMs, bucket); + const purchases = buckets.reduce((a, b) => a + b.purchases, 0); + const revenue = buckets.reduce((a, b) => a + b.revenue, 0); + const visitors = buckets.reduce((a, b) => a + b.visitors, 0); + const prev = buildTimeseries(range, storeId, previousEnd(range, endMs)); + return { + purchases, + revenue, + conversionPct: visitors ? Number(((purchases / (visitors * 0.72)) * 100).toFixed(1)) : null, + basketSize: purchases ? Math.round(revenue / purchases) : null, + buckets, + timezone: 'Asia/Kolkata', + previousPurchases: prev.reduce((a, p) => a + p.purchases, 0), + previousRevenue: prev.reduce((a, p) => a + p.revenue, 0), + }; +} + +const SAMPLE_CUSTOMERS = ['Priya S.', 'Arjun M.', 'Kavya R.', 'Rahul N.', 'Sneha P.', 'Vikram T.', 'Ananya K.', 'Rohan D.']; +const SAMPLE_CASHIERS = ['Meena', 'Karthik', 'Divya', 'Suresh']; +/** Keeps a 90-day all-stores list from building tens of thousands of rows. */ +const MAX_SAMPLE_SALES = 1500; +const IST_OFFSET_MS = 5.5 * 3600000; + +/** + * Bills for the sale list. `siteIds` are the workspace's REAL store ids, so + * Top performing outlets names the merchant's own shops. + */ +export function buildSampleSales( + range: RangeKey, + storeId: string, + siteIds: string[], + endMs: number, +): Sale[] { + const days = RANGE_DAYS[range]; + const sites = siteIds.length ? siteIds : [storeId]; + const perDayCap = Math.max(4, Math.floor(MAX_SAMPLE_SALES / days)); + const sales: Sale[] = []; + + buildTimeseries(range, storeId, endMs).forEach((p, dayIdx) => { + const rng = createRng('sale', storeId, range, p.t); + const count = Math.min(perDayCap, Math.max(3, Math.round(p.purchases / 6))); + const dayStart = Date.parse(`${p.t}T00:00:00Z`) - IST_OFFSET_MS; + for (let i = 0; i < count; i++) { + // Shop hours 10:00–21:00 IST, with an evening lean. + const hour = Math.min(21, Math.floor(10 + 11 * Math.sqrt(rng.next()))); + const at = dayStart + hour * 3600000 + rng.int(0, 59) * 60000; + const purchasedLines = rng.int(1, 4); + const enquiryLines = rng.next() < 0.3 ? rng.int(1, 2) : 0; + const totalPaise = purchasedLines * rng.int(249, 1299) * 100; + const known = rng.next() < 0.6; + sales.push({ + id: `sample-${p.t}-${i}`, + invoiceNo: `INV-${p.t.replaceAll('-', '').slice(2)}-${String(dayIdx * 100 + i).padStart(4, '0')}`, + siteId: sites[rng.int(0, sites.length - 1)], + customerRef: known ? `cust-${rng.int(100, 999)}` : null, + customerLabel: known ? rng.pick(SAMPLE_CUSTOMERS) : null, + staffName: rng.pick(SAMPLE_CASHIERS), + totalPaise, + currency: 'INR', + status: rng.next() < 0.02 ? 'void' : 'completed', + at: new Date(at).toISOString(), + purchasedLines, + enquiryLines, + lines: [], + }); + } + }); + + return sales.sort((a, b) => b.at.localeCompare(a.at)); +} + +/** The engagement catalogue, from the same activity metrics the intelligence panels use. */ +export function buildSampleActivityRows( + range: RangeKey, + storeId: string, + endMs: number, +): ActivityRow[] { + return buildActivityMetrics(range, storeId, endMs) + .filter((m) => m.status !== 'draft') + .map((m) => ({ + id: m.id, + kind: m.id, + name: m.label, + isActive: m.status === 'live', + events: m.count, + participants: m.impact.customers, + anonymous: Math.round(m.impact.customers * 0.12), + repeatVisitors: m.impact.repeatVisits, + purchases: m.impact.purchases, + attributedRevenue: m.impact.attributedRevenueInr, + currency: 'INR', + attribution: 'estimated' as const, + })); +} + +export function buildSampleCampaignRows( + range: RangeKey, + storeId: string, + endMs: number, +): Campaign[] { + const metrics = buildActivityMetrics(range, storeId, endMs); + return buildCampaigns(range, storeId, endMs).map((c, i) => { + const m = metrics.find((x) => x.id === c.activityId); + const startDaysAgo = [24, 40, 18, 60][i % 4]; + const isActive = c.status === 'live'; + return { + id: c.id, + name: c.name, + startsAt: new Date(endMs - startDaysAgo * 86400000).toISOString(), + endsAt: isActive ? null : new Date(endMs - 6 * 86400000).toISOString(), + isActive, + activities: [c.activityId], + events: c.steps[0].value, + participants: m?.impact.customers ?? c.steps[0].value, + purchasers: c.steps[2].value, + attributedRevenue: c.attributedRevenueInr, + attribution: c.attribution, + }; + }); +} + +export function buildSampleJourney(range: RangeKey, storeId: string, endMs: number): Journey { + return { + stages: buildJourney(range, storeId, endMs).map((s) => ({ + id: s.id, + label: s.label, + customers: s.value, + })), + attribution: 'estimated', + }; +} diff --git a/src/shared/mocks/withSample.ts b/src/shared/mocks/withSample.ts index cc07473..41be1b1 100644 --- a/src/shared/mocks/withSample.ts +++ b/src/shared/mocks/withSample.ts @@ -6,8 +6,11 @@ * useSampleResource() no endpoint exists at all; the sample IS the data, and * the panel sits inside a . * - * A section that HAS a real endpoint never falls back to sample data: an - * empty response renders the panel's empty state. + * useSampleFallback() a real endpoint exists but returned nothing (or + * failed); an example stands in and the panel wears + * the "Sample data" token via `resource.isSample`. + * Off with NEXT_PUBLIC_SAMPLE_FALLBACK=off, which + * restores the plain empty states. * * Every sample value comes from shared/mocks/sampleData.ts — the single file * to edit, and eventually delete. @@ -65,3 +68,46 @@ export function useSampleResource( // eslint-disable-next-line react-hooks/exhaustive-deps }, [key]); } + +/** Set NEXT_PUBLIC_SAMPLE_FALLBACK=off to show real empty states instead. */ +export const SAMPLE_FALLBACK = process.env.NEXT_PUBLIC_SAMPLE_FALLBACK !== 'off'; + +const hasRows = (d: unknown) => !Array.isArray(d) || d.length > 0; + +/** + * The real resource while it carries anything; otherwise a sample of the + * same shape, marked `isSample`. Loading stays loading — a sample never + * flashes in ahead of a real response. + * + * `hasData` decides what "carries anything" means: a report whose buckets + * are all zero arrives as success but is still nothing to draw. + */ +export function useSampleFallback( + real: Resource, + build: (nowMs: number) => T, + deps: readonly unknown[], + hasData: (data: T) => boolean = hasRows, +): Resource { + const needed = + SAMPLE_FALLBACK && + (real.status === 'empty' || + real.status === 'error' || + (real.status === 'success' && !hasData(real.data))); + const key = JSON.stringify(deps); + const {refetch} = real; + const sample = useMemo | null>(() => { + if (!needed) return null; + const nowMs = sampleNowMs(); + return { + status: 'success', + data: build(nowMs), + error: undefined, + refetch, + isRefreshing: false, + meta: {generatedAt: new Date(nowMs).toISOString()}, + isSample: true, + } as Resource; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [needed, key, refetch]); + return sample ?? real; +} diff --git a/src/shared/theme/themeMode.ts b/src/shared/theme/themeMode.ts index 0fd7262..703875d 100644 --- a/src/shared/theme/themeMode.ts +++ b/src/shared/theme/themeMode.ts @@ -20,10 +20,10 @@ export const THEME_MODE_COOKIE = 'loyaly.theme-mode'; export const THEME_MODE_MAX_AGE = 60 * 60 * 24 * 365; /** - * Dark is the product's identity, so it is what an unknown visitor gets — - * not 'system'. Only an explicit choice moves off it. + * Light is what an unknown visitor gets — not 'system', so the console looks + * the same on every machine until somebody picks otherwise in Preferences. */ -export const DEFAULT_THEME_MODE: ThemeMode = 'dark'; +export const DEFAULT_THEME_MODE: ThemeMode = 'light'; export function parseThemeMode(value: string | undefined | null): ThemeMode { return value === 'light' || value === 'dark' || value === 'system'