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
42 changes: 36 additions & 6 deletions src/app/(dashboard)/influencers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ import {
estimateRevenueFromViews,
formatEstimatedRevenue,
} from '@/lib/revenue-estimate';
import {
DEFAULT_TAX_PERIOD_DAYS,
estimateTaxForPeriod,
TAX_PERIOD_OPTIONS,
} from '@/lib/tax-period-estimate';

const COMPLIANCE_STATUSES = ['compliant', 'non-compliant', 'pending', 'under-review'] as const;

Expand Down Expand Up @@ -80,6 +85,7 @@ export default function InfluencersPage() {
);
const [showAddDialog, setShowAddDialog] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [taxPeriodDays, setTaxPeriodDays] = useState<number>(DEFAULT_TAX_PERIOD_DAYS);
const [form, setForm] = useState({
name: '',
handle: '',
Expand Down Expand Up @@ -154,6 +160,9 @@ export default function InfluencersPage() {
filtered = filtered.filter((channel) => channel.complianceStatus === filterStatus);
}

const selectedTaxPeriod =
TAX_PERIOD_OPTIONS.find((option) => option.days === taxPeriodDays) ?? TAX_PERIOD_OPTIONS[0];

const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.name || !form.handle) return;
Expand Down Expand Up @@ -261,6 +270,24 @@ export default function InfluencersPage() {
))}
</SelectContent>
</Select>

<Select
value={String(taxPeriodDays)}
onValueChange={(value) => {
setTaxPeriodDays(Number(value));
}}
>
<SelectTrigger className='w-full bg-card sm:w-44'>
<SelectValue placeholder='Tax period' />
</SelectTrigger>
<SelectContent>
{TAX_PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<div className='overflow-hidden rounded-xl border border-border/60 bg-card'>
Expand Down Expand Up @@ -298,7 +325,9 @@ export default function InfluencersPage() {
</td>
</tr>
) : (
filtered.map((channel) => (
filtered.map((channel) => {
const periodTax = estimateTaxForPeriod(channel, taxPeriodDays);
return (
<tr
key={channel._id}
className='border-b border-border/40 align-top transition-colors hover:bg-muted/20'
Expand Down Expand Up @@ -370,12 +399,12 @@ export default function InfluencersPage() {

<td className='px-4 py-4 text-right'>
<p className='font-mono text-xs font-medium text-chart-5'>
{channel.estimatedTax !== undefined ? formatCurrency(channel.estimatedTax) : '--'}
{periodTax !== undefined ? formatCurrency(periodTax) : '--'}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
{channel.taxEstimateSource === 'none'
? 'No estimate yet'
: formatRevenueSource(channel.taxEstimateSource)}
{periodTax !== undefined
? `Projected for ${selectedTaxPeriod.label}`
: 'No estimate yet'}
</p>
</td>

Expand Down Expand Up @@ -413,7 +442,8 @@ export default function InfluencersPage() {
</div>
</td>
</tr>
))
);
})
)}
</tbody>
</table>
Expand Down
52 changes: 48 additions & 4 deletions src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,24 @@ import {
import { api } from '~convex/_generated/api';

import Link from 'next/link';
import { useMemo, useState } from 'react';

import { Button } from '@/components/ui/button';
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { formatCompactNumber, formatCurrency, formatRevenueSource } from '@/lib/product';
import {
DEFAULT_TAX_PERIOD_DAYS,
estimateTaxForPeriod,
TAX_PERIOD_OPTIONS,
} from '@/lib/tax-period-estimate';

function MetricCard({
label,
Expand Down Expand Up @@ -67,12 +80,26 @@ function DashboardSkeleton() {
}

export default function OverviewPage() {
const [taxPeriodDays, setTaxPeriodDays] = useState<number>(DEFAULT_TAX_PERIOD_DAYS);
const stats = useQuery(api.influencers.getInfluencerStats);
const channels = useQuery(api.influencers.getChannels, {});
const revenueData = useQuery(api.analytics.getRevenueByMonth);
const topChannels = useQuery(api.analytics.getTopInfluencers);
const recentLogs = useQuery(api.auditLogs.getRecentLogs, { limit: 5 });
const selectedTaxPeriod = useMemo(
() => TAX_PERIOD_OPTIONS.find((option) => option.days === taxPeriodDays) ?? TAX_PERIOD_OPTIONS[0],
[taxPeriodDays],
);
const totalTaxForPeriod = useMemo(
() =>
(channels ?? []).reduce(
(sum, channel) => sum + (estimateTaxForPeriod(channel, taxPeriodDays) ?? 0),
0,
),
[channels, taxPeriodDays],
);
Comment on lines +85 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Aggregate the tax total server-side instead of fetching every channel.

This page now downloads full channel summaries just to compute totalTaxForPeriod, and it blocks the entire dashboard until that query resolves. Per convex/channelData.ts, those summaries include fields like email, phone, taxIdNumber, and notes, so this also broadens client-side PII exposure for an aggregate metric. Prefer a Convex query that returns only the period tax total, or at least a minimal revenue-only projection.

♻️ Directional refactor
-  const channels = useQuery(api.influencers.getChannels, {});
+  const totalTaxForPeriod = useQuery(api.influencers.getTaxEstimateForPeriod, {
+    periodDays: taxPeriodDays,
+  });
...
-  const totalTaxForPeriod = useMemo(
-    () =>
-      (channels ?? []).reduce(
-        (sum, channel) => sum + (estimateTaxForPeriod(channel, taxPeriodDays) ?? 0),
-        0,
-      ),
-    [channels, taxPeriodDays],
-  );
...
-  if (stats === undefined || channels === undefined) {
+  if (stats === undefined || totalTaxForPeriod === undefined) {
     return <DashboardSkeleton />;
   }

Also applies to: 102-104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(dashboard)/page.tsx around lines 85 - 100, The page is fetching
full channel summaries via useQuery(api.influencers.getChannels) just to compute
totalTaxForPeriod, exposing PII and blocking rendering; replace that client-side
aggregation with a server-side Convex query that returns only the period tax
total (or a minimal revenue-only projection). Add/use a new API hook (e.g.
api.analytics.getTotalTaxForPeriod or similar) that accepts taxPeriodDays,
remove the useQuery call to api.influencers.getChannels and stop using channels
in totalTaxForPeriod and estimateTaxForPeriod on the client; instead call the
new server-side query and use its numeric result directly wherever
totalTaxForPeriod is referenced so the client no longer downloads channel
summaries or uses estimateTaxForPeriod for aggregation.


if (stats === undefined) {
if (stats === undefined || channels === undefined) {
return <DashboardSkeleton />;
}

Expand All @@ -86,8 +113,8 @@ export default function OverviewPage() {
/>
<MetricCard
label='Estimated Tax Output'
value={formatCurrency(stats.totalEstimatedTax, { compact: true })}
subtitle='Derived separately from public metadata and source inputs'
value={formatCurrency(totalTaxForPeriod, { compact: true })}
subtitle={`Projected for ${selectedTaxPeriod.label} from available source data`}
accentClass='text-chart-5'
/>
<MetricCard
Expand All @@ -111,13 +138,30 @@ export default function OverviewPage() {
Revenue Inputs &amp; Tax Estimates
</CardTitle>
<CardAction>
<div className='flex gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<span className='flex items-center gap-1.5 text-[10px] font-medium tracking-wider text-muted-foreground uppercase'>
<span className='h-2 w-2 rounded-full bg-[oklch(0.6_0.18_250)]' /> Revenue inputs
</span>
<span className='flex items-center gap-1.5 text-[10px] font-medium tracking-wider text-muted-foreground uppercase'>
<span className='h-2 w-2 rounded-full bg-[oklch(0.65_0.18_150)]' /> Tax estimates
</span>
<Select
value={String(taxPeriodDays)}
onValueChange={(value) => {
setTaxPeriodDays(Number(value));
}}
>
<SelectTrigger className='h-8 w-[110px] text-[10px] tracking-wider uppercase'>
<SelectValue placeholder='Tax period' />
</SelectTrigger>
<SelectContent>
{TAX_PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
Comment on lines +141 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the period selector scoped to data it actually changes.

The selector sits in the chart header next to the “Tax estimates” legend, but the chart still renders revenueData.tax unchanged. Selecting “90 days” changes the metric card, not the chart, which makes the chart appear period-aware when it is not. Either move the selector next to the metric it controls or derive/label a periodized chart series.

Also applies to: 170-232

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(dashboard)/page.tsx around lines 141 - 164, The period selector
(Select using value={String(taxPeriodDays)} and setter setTaxPeriodDays)
currently sits by the chart legend but only updates the metric card while the
chart still renders revenueData.tax unchanged; either move the Select and
TAX_PERIOD_OPTIONS UI next to the metric card it controls or make the chart
consume taxPeriodDays so its series are filtered/derived from revenueData.tax by
period (update the chart rendering logic that currently reads revenueData.tax to
compute series using taxPeriodDays). Ensure the Select remains wired to
taxPeriodDays/setTaxPeriodDays and update labels/legend to reflect whether the
chart is periodized.

</div>
</CardAction>
</CardHeader>
Expand Down
88 changes: 88 additions & 0 deletions src/lib/tax-period-estimate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { estimateRevenueFromViews } from './revenue-estimate';

export const TAX_PERIOD_OPTIONS = [
{ days: 30, label: '30 days' },
{ days: 60, label: '60 days' },
{ days: 90, label: '90 days' },
{ days: 365, label: '1 year' },
] as const;

export const DEFAULT_TAX_PERIOD_DAYS = TAX_PERIOD_OPTIONS[0].days;
const DAYS_IN_YEAR = 365;

// Ghana Revenue Authority progressive personal income tax brackets (annual, GHS).
const GHA_TAX_BRACKETS: Array<{ limit: number; rate: number }> = [
{ limit: 5_880, rate: 0 },
{ limit: 1_320, rate: 0.05 },
{ limit: 1_560, rate: 0.1 },
{ limit: 38_000, rate: 0.175 },
{ limit: 192_000, rate: 0.25 },
{ limit: 366_240, rate: 0.3 },
{ limit: Infinity, rate: 0.35 },
];

function calculateProgressiveTax(
income: number,
brackets: Array<{ limit: number; rate: number }>,
): number {
if (income <= 0) return 0;

let tax = 0;
let remaining = income;

for (const bracket of brackets) {
if (remaining <= 0) break;
const taxable = Number.isFinite(bracket.limit) ? Math.min(remaining, bracket.limit) : remaining;
tax += taxable * bracket.rate;
remaining -= taxable;
}

return Math.round(tax);
}

function estimateAnnualRevenue(channel: {
estimatedAnnualRevenue?: number;
estimatedMonthlyRevenue?: number;
totalViews?: number;
topicCategories?: string[];
channelCreatedAt?: number;
}) {
if (channel.estimatedAnnualRevenue !== undefined) {
return channel.estimatedAnnualRevenue;
}

if (channel.estimatedMonthlyRevenue !== undefined) {
return channel.estimatedMonthlyRevenue * 12;
}

if (channel.totalViews !== undefined) {
const lifetimeRevenue = estimateRevenueFromViews(channel.totalViews, channel.topicCategories ?? []);
if (!channel.channelCreatedAt) {
return lifetimeRevenue;
}

const ageDays = Math.max(30, (Date.now() - channel.channelCreatedAt) / (1000 * 60 * 60 * 24));
return lifetimeRevenue * (DAYS_IN_YEAR / ageDays);
Comment on lines +58 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t annualize lifetime view revenue without an age signal.

When totalViews exists but channelCreatedAt is missing, Line 61 treats lifetime estimated revenue as annual revenue. That makes the downstream “Projected for 30/60/90 days” tax label look precise even though the period basis is unknown. Prefer returning undefined until an age/source period is available, or label this path explicitly as lifetime-derived.

🐛 Proposed fix
   if (channel.totalViews !== undefined) {
     const lifetimeRevenue = estimateRevenueFromViews(channel.totalViews, channel.topicCategories ?? []);
-    if (!channel.channelCreatedAt) {
-      return lifetimeRevenue;
+    if (channel.channelCreatedAt === undefined) {
+      return undefined;
     }
 
-    const ageDays = Math.max(30, (Date.now() - channel.channelCreatedAt) / (1000 * 60 * 60 * 24));
+    const elapsedMs = Date.now() - channel.channelCreatedAt;
+    if (elapsedMs <= 0) {
+      return undefined;
+    }
+
+    const ageDays = Math.max(30, elapsedMs / (1000 * 60 * 60 * 24));
     return lifetimeRevenue * (DAYS_IN_YEAR / ageDays);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/tax-period-estimate.ts` around lines 58 - 65, The code currently
treats lifetime revenue from estimateRevenueFromViews(channel.totalViews,
channel.topicCategories ?? []) as annualized when channel.channelCreatedAt is
missing; change the logic so that if channel.totalViews is present but
channel.channelCreatedAt is undefined you return undefined (or otherwise mark
the value as lifetime-derived) instead of computing lifetimeRevenue *
(DAYS_IN_YEAR / ageDays); update the block around the checks for
channel.totalViews and channel.channelCreatedAt (referencing
estimateRevenueFromViews, channel.totalViews, channel.channelCreatedAt, and
DAYS_IN_YEAR) to only annualize when channelCreatedAt is available and otherwise
return undefined.

}

return undefined;
}

export function estimateTaxForPeriod(channel: {
estimatedAnnualRevenue?: number;
estimatedMonthlyRevenue?: number;
totalViews?: number;
topicCategories?: string[];
channelCreatedAt?: number;
}, periodDays: number): number | undefined {
const annualRevenue = estimateAnnualRevenue(channel);
if (annualRevenue === undefined) return undefined;

const periodRevenue = (annualRevenue * periodDays) / DAYS_IN_YEAR;
const periodBrackets = GHA_TAX_BRACKETS.map((bracket) => ({
limit: Number.isFinite(bracket.limit) ? (bracket.limit * periodDays) / DAYS_IN_YEAR : Infinity,
rate: bracket.rate,
}));

return calculateProgressiveTax(periodRevenue, periodBrackets);
Comment on lines +71 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard the exported estimator against invalid periods.

estimateTaxForPeriod accepts any number; NaN, Infinity, zero, or negative periods can return NaN or meaningless tax values. Since this is exported from the utility module, add a runtime guard even if current UI values come from TAX_PERIOD_OPTIONS.

🛡️ Proposed fix
 export function estimateTaxForPeriod(channel: {
   estimatedAnnualRevenue?: number;
   estimatedMonthlyRevenue?: number;
   totalViews?: number;
   topicCategories?: string[];
   channelCreatedAt?: number;
 }, periodDays: number): number | undefined {
+  if (!Number.isFinite(periodDays) || periodDays <= 0) {
+    return undefined;
+  }
+
   const annualRevenue = estimateAnnualRevenue(channel);
   if (annualRevenue === undefined) return undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function estimateTaxForPeriod(channel: {
estimatedAnnualRevenue?: number;
estimatedMonthlyRevenue?: number;
totalViews?: number;
topicCategories?: string[];
channelCreatedAt?: number;
}, periodDays: number): number | undefined {
const annualRevenue = estimateAnnualRevenue(channel);
if (annualRevenue === undefined) return undefined;
const periodRevenue = (annualRevenue * periodDays) / DAYS_IN_YEAR;
const periodBrackets = GHA_TAX_BRACKETS.map((bracket) => ({
limit: Number.isFinite(bracket.limit) ? (bracket.limit * periodDays) / DAYS_IN_YEAR : Infinity,
rate: bracket.rate,
}));
return calculateProgressiveTax(periodRevenue, periodBrackets);
export function estimateTaxForPeriod(channel: {
estimatedAnnualRevenue?: number;
estimatedMonthlyRevenue?: number;
totalViews?: number;
topicCategories?: string[];
channelCreatedAt?: number;
}, periodDays: number): number | undefined {
if (!Number.isFinite(periodDays) || periodDays <= 0) {
return undefined;
}
const annualRevenue = estimateAnnualRevenue(channel);
if (annualRevenue === undefined) return undefined;
const periodRevenue = (annualRevenue * periodDays) / DAYS_IN_YEAR;
const periodBrackets = GHA_TAX_BRACKETS.map((bracket) => ({
limit: Number.isFinite(bracket.limit) ? (bracket.limit * periodDays) / DAYS_IN_YEAR : Infinity,
rate: bracket.rate,
}));
return calculateProgressiveTax(periodRevenue, periodBrackets);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/tax-period-estimate.ts` around lines 71 - 87, The exported function
estimateTaxForPeriod should guard against invalid periodDays (NaN, Infinity,
zero or negative) to avoid producing NaN/meaningless taxes; update
estimateTaxForPeriod to validate that Number.isFinite(periodDays) && periodDays
> 0 and return undefined for invalid input before using DAYS_IN_YEAR, computing
periodRevenue, mapping GHA_TAX_BRACKETS, or calling calculateProgressiveTax so
callers get a safe undefined instead of broken results.

}
Loading