Repository navigation
refactor(ai): AI status in overview settings page #857
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
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 |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group' | ||
| import { cn } from 'cn' | ||
|
|
||
| import { formatDay, formatPoints, type ProtoUsage, usedFeatures } from './data' | ||
| import { DetailsLink, InfinityTile } from './shared' | ||
|
|
||
| const SEGMENT_TONES = ['bg-primary', 'bg-primary/65', 'bg-primary/40', 'bg-primary/20'] | ||
| const MAX_SEGMENTS = 3 | ||
|
|
||
| const percent = new Intl.NumberFormat('en', { maximumFractionDigits: 0, style: 'percent' }) | ||
|
|
||
| export const Breakdown = ({ usage }: { usage: ProtoUsage }) => { | ||
| const features = usedFeatures(usage) | ||
| const top = features.slice(0, MAX_SEGMENTS) | ||
| const rest = features.slice(MAX_SEGMENTS) | ||
| const segments = [ | ||
| ...top.map(feature => ({ key: feature.key, label: feature.title, points: feature.monthPoints })), | ||
| ...(rest.length > 0 | ||
| ? [ | ||
| { | ||
| key: 'other', | ||
| label: `${rest.length} other ${rest.length === 1 ? 'feature' : 'features'}`, | ||
| points: rest.reduce((sum, feature) => sum + feature.monthPoints, 0), | ||
| }, | ||
| ] | ||
| : []), | ||
| ] | ||
|
|
||
| return ( | ||
| <SettingsGroup title="AI points"> | ||
| <li className={cn(SETTINGS_ROW, 'items-start')}> | ||
| <InfinityTile /> | ||
| <div className="flex min-w-0 flex-1 flex-col gap-0.5"> | ||
| <span className="text-sm font-medium">Unlimited points</span> | ||
| <span className="text-muted-foreground text-sm leading-relaxed text-pretty"> | ||
| No monthly cap. Your usage count starts over on {formatDay(usage.resetsAt)}. | ||
| </span> | ||
| </div> | ||
| <div className="flex shrink-0 flex-col items-end gap-0.5"> | ||
| <span className="text-lg leading-none font-semibold tabular-nums">{formatPoints(usage.used)}</span> | ||
| <span className="text-muted-foreground text-xs">pts this month</span> | ||
| </div> | ||
| </li> | ||
| <li className={cn(SETTINGS_ROW, 'flex-col items-stretch gap-3')}> | ||
| {segments.length === 0 ? ( | ||
| <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> | ||
| Nothing used since {formatDay(usage.periodStart)}. Once you run an AI feature, you'll see where your points go. | ||
| </p> | ||
| ) : ( | ||
| <> | ||
| <div | ||
| aria-hidden | ||
| className="bg-muted flex h-2 w-full gap-0.5 overflow-hidden rounded-full" | ||
| > | ||
| {segments.map((segment, index) => ( | ||
| <span | ||
| className={cn('h-full min-w-1', SEGMENT_TONES[index])} | ||
| key={segment.key} | ||
| style={{ flexGrow: segment.points }} | ||
| /> | ||
| ))} | ||
| </div> | ||
| <ul aria-label="Where your points went this month" className="flex flex-col gap-2"> | ||
| {segments.map((segment, index) => ( | ||
| <li className="flex items-center gap-3 text-sm" key={segment.key}> | ||
| <span aria-hidden className={cn('size-2 shrink-0 rounded-full', SEGMENT_TONES[index])} /> | ||
| <span className="min-w-0 flex-1 truncate">{segment.label}</span> | ||
| <span className="text-muted-foreground shrink-0 tabular-nums">{formatPoints(segment.points)} pts</span> | ||
| <span className="w-10 shrink-0 text-end font-medium tabular-nums"> | ||
| {percent.format(segment.points / usage.used)} | ||
| </span> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </> | ||
| )} | ||
| </li> | ||
| <li className={SETTINGS_ROW}> | ||
| <DetailsLink /> | ||
| </li> | ||
| </SettingsGroup> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group' | ||
|
|
||
| import { formatPoints, type ProtoUsage } from './data' | ||
| import { DetailsLink } from './shared' | ||
|
|
||
| export const Current = ({ usage }: { usage: ProtoUsage }) => ( | ||
| <SettingsGroup title="AI points"> | ||
| <li className={SETTINGS_ROW}> | ||
| <p className="text-sm leading-relaxed"> | ||
| <span className="font-medium">Unlimited points</span>{' '} | ||
| <span className="text-muted-foreground tabular-nums">{formatPoints(usage.used)} points used this month</span> | ||
| </p> | ||
| </li> | ||
| <li className={SETTINGS_ROW}> | ||
| <DetailsLink /> | ||
| </li> | ||
| </SettingsGroup> | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { AiActionIcon } from '@vitnode/core/components/ai/action-icon' | ||
| import { Badge } from '@vitnode/core/components/ui/badge' | ||
| import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group' | ||
| import { cn } from 'cn' | ||
|
|
||
| import { formatPoints, type ProtoUsage } from './data' | ||
| import { DetailsLink, InfinityTile } from './shared' | ||
|
|
||
| export const DailyLimits = ({ usage }: { usage: ProtoUsage }) => { | ||
| const limited = usage.features.filter(feature => feature.dailyLimit !== null) | ||
| const open = usage.features.length - limited.length | ||
|
|
||
| return ( | ||
| <SettingsGroup title="AI points"> | ||
| <li className={cn(SETTINGS_ROW, 'items-start')}> | ||
| <InfinityTile /> | ||
| <div className="flex min-w-0 flex-1 flex-col gap-0.5"> | ||
| <span className="text-sm font-medium">No monthly cap on your points</span> | ||
| <span className="text-muted-foreground text-sm leading-relaxed text-pretty"> | ||
| Only daily limits can pause a feature. They reset at midnight. | ||
| </span> | ||
| </div> | ||
| </li> | ||
| {limited.map(feature => { | ||
| const limit = feature.dailyLimit ?? 0 | ||
| const isFull = feature.usedToday >= limit | ||
| const share = Math.min(feature.usedToday / limit, 1) | ||
|
|
||
| return ( | ||
| <li className={SETTINGS_ROW} key={feature.key}> | ||
| <AiActionIcon enabled={!isFull} icon={feature.icon} /> | ||
| <span className="min-w-0 flex-1 truncate text-sm">{feature.title}</span> | ||
| {isFull ? ( | ||
| <Badge variant="destructive">Limit reached</Badge> | ||
| ) : ( | ||
| <span className="flex shrink-0 items-center gap-3"> | ||
| <span | ||
| aria-hidden | ||
| className="bg-muted hidden h-1.5 w-20 overflow-hidden rounded-full sm:block" | ||
| > | ||
| <span | ||
| className={cn('block h-full rounded-full', share >= 0.8 ? 'bg-warn' : 'bg-primary')} | ||
| style={{ width: `${share * 100}%` }} | ||
| /> | ||
| </span> | ||
| <span className="text-muted-foreground min-w-24 text-end text-sm whitespace-nowrap tabular-nums"> | ||
| {feature.usedToday} / {limit} today | ||
| </span> | ||
| </span> | ||
| )} | ||
| </li> | ||
| ) | ||
| })} | ||
| <li className={cn(SETTINGS_ROW, 'flex-wrap justify-between gap-y-1')}> | ||
| <span className="text-muted-foreground text-sm tabular-nums"> | ||
| {open > 0 ? `${open} more ${open === 1 ? 'feature has' : 'features have'} no daily limit · ` : ''} | ||
| {formatPoints(usage.used)} pts used this month | ||
| </span> | ||
| <DetailsLink>All features</DetailsLink> | ||
| </li> | ||
| </SettingsGroup> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| export interface ProtoFeature { | ||
| dailyLimit: null | number | ||
| icon: string | ||
| key: string | ||
| monthPoints: number | ||
| title: string | ||
| usedToday: number | ||
| } | ||
|
|
||
| export interface ProtoUsage { | ||
| features: ProtoFeature[] | ||
| periodStart: Date | ||
| resetsAt: Date | ||
| used: number | ||
| } | ||
|
|
||
| const periodStart = new Date(2026, 9, 1) | ||
| const resetsAt = new Date(2026, 10, 1) | ||
|
|
||
| const heavy: ProtoFeature[] = [ | ||
| { dailyLimit: 20, icon: 'text-quote', key: 'summarize', monthPoints: 612.4, title: 'Summarize thread', usedToday: 14 }, | ||
| { dailyLimit: null, icon: 'languages', key: 'translate', monthPoints: 388.9, title: 'Translate article into another language while keeping the formatting', usedToday: 9 }, | ||
| { dailyLimit: 10, icon: 'wand-sparkles', key: 'rewrite', monthPoints: 171.2, title: 'Rewrite with a friendlier tone', usedToday: 10 }, | ||
| { dailyLimit: 50, icon: 'image', key: 'alt', monthPoints: 64.3, title: 'Generate alt text', usedToday: 3 }, | ||
| { dailyLimit: null, icon: 'tags', key: 'tags', monthPoints: 31.6, title: 'Suggest tags', usedToday: 1 }, | ||
| { dailyLimit: null, icon: 'message-square-reply', key: 'reply', monthPoints: 16.2, title: 'Draft reply', usedToday: 0 }, | ||
| ] | ||
|
|
||
| const light: ProtoFeature[] = [ | ||
| { dailyLimit: 20, icon: 'text-quote', key: 'summarize', monthPoints: 0.2, title: 'Summarize thread', usedToday: 1 }, | ||
| { dailyLimit: null, icon: 'languages', key: 'translate', monthPoints: 0.1, title: 'Translate article into another language while keeping the formatting', usedToday: 0 }, | ||
| { dailyLimit: 10, icon: 'wand-sparkles', key: 'rewrite', monthPoints: 0, title: 'Rewrite with a friendlier tone', usedToday: 0 }, | ||
| ] | ||
|
|
||
| const fresh: ProtoFeature[] = light.map(feature => ({ ...feature, monthPoints: 0, usedToday: 0 })) | ||
|
|
||
| const build = (features: ProtoFeature[]): ProtoUsage => ({ | ||
| features, | ||
| periodStart, | ||
| resetsAt, | ||
| used: features.reduce((sum, feature) => sum + feature.monthPoints, 0), | ||
| }) | ||
|
|
||
| export const SCENARIOS = { | ||
| heavy: { label: 'Heavy use', usage: build(heavy) }, | ||
| light: { label: 'Light use (0.3 pts)', usage: build(light) }, | ||
| fresh: { label: 'Nothing used yet', usage: build(fresh) }, | ||
| } as const | ||
|
|
||
| export type ScenarioKey = keyof typeof SCENARIOS | ||
|
|
||
| export const formatPoints = (value: number) => { | ||
| if (value > 0 && value < 0.1) return '<0.1' | ||
| return new Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format(Math.floor(value * 10) / 10) | ||
| } | ||
|
|
||
| export const formatDay = (date: Date) => | ||
| new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long' }).format(date) | ||
|
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. React Doctor · This is slow because new Intl.DateTimeFormat() rebuilds on every call inside a function, so move it to the top of the file, or wrap it in useMemo Fix → Move |
||
|
|
||
| export const runsToday = (usage: ProtoUsage) => | ||
| usage.features.reduce((sum, feature) => sum + feature.usedToday, 0) | ||
|
|
||
| export const usedFeatures = (usage: ProtoUsage) => | ||
| [...usage.features].filter(feature => feature.monthPoints > 0).sort((a, b) => b.monthPoints - a.monthPoints) | ||
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.
React Doctor ·
react-doctor/js-hoist-intl(warning)This is slow because new Intl.NumberFormat() rebuilds on every call inside a function, so move it to the top of the file, or wrap it in useMemo
Fix → Move
new Intl.NumberFormat(...)to the top of the file or wrap it inuseMemo. Building one is slow, so don't redo it on every callDocs