-
Notifications
You must be signed in to change notification settings - Fork 1
Enhance dashboard with selectable periodic tax estimation #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d9442e4
f1b22e4
bdf3fc4
ac4c6af
eab57dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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], | ||
| ); | ||
|
|
||
| if (stats === undefined) { | ||
| if (stats === undefined || channels === undefined) { | ||
| return <DashboardSkeleton />; | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -111,13 +138,30 @@ export default function OverviewPage() { | |
| Revenue Inputs & 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 Also applies to: 170-232 🤖 Prompt for AI Agents |
||
| </div> | ||
| </CardAction> | ||
| </CardHeader> | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t annualize lifetime view revenue without an age signal. When 🐛 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard the exported estimator against invalid periods.
🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. Perconvex/channelData.ts, those summaries include fields likeemail,phone,taxIdNumber, andnotes, 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
Also applies to: 102-104
🤖 Prompt for AI Agents