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
49 changes: 27 additions & 22 deletions .agents/skills/interface-design/SKILL.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .agents/skills/interface-design/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
interface:
display_name: "Interface Design"
short_description: "Craft-first product UI guidance"
default_prompt: "Use $interface-design to design or refine a product interface with a domain-specific visual system and image-based references when useful."
display_name: 'Interface Design'
short_description: 'Craft-first product UI guidance'
default_prompt: 'Use $interface-design to design or refine a product interface with a domain-specific visual system and image-based references when useful.'
policy:
allow_implicit_invocation: true
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
.convex
.convex
convex/_generated
13 changes: 9 additions & 4 deletions convex/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const overview = authedQuery({
date: v.string(),
amount: v.number(),
merchantName: v.optional(v.string()),
categoryId: v.optional(v.id('categories')),
categoryName: v.optional(v.string()),
categoryColor: v.optional(v.string()),
}),
Expand Down Expand Up @@ -179,6 +180,7 @@ export const overview = authedQuery({
date: t.date,
amount: t.amount,
merchantName: t.merchantName ?? t.originalDescription,
categoryId: t.categoryId,
categoryName: cat?.name,
categoryColor: cat?.color,
}
Expand Down Expand Up @@ -257,16 +259,19 @@ export const spendingPace = authedQuery({
const todayMonth = args.today.slice(0, 7)
const todayDay = Number(args.today.slice(8, 10))
const throughDay =
todayMonth === args.month
? Math.min(days, Math.max(1, todayDay))
: days
todayMonth === args.month ? Math.min(days, Math.max(1, todayDay)) : days

const lastMonthKey = shiftMonth(args.month, -1)
const lastYearKey = shiftMonth(args.month, -12)

const [thisMonth, lastMonth, lastYear] = await Promise.all([
cumulativeSpend(ctx, ctx.user._id, args.month, throughDay),
cumulativeSpend(ctx, ctx.user._id, lastMonthKey, daysInMonth(lastMonthKey)),
cumulativeSpend(
ctx,
ctx.user._id,
lastMonthKey,
daysInMonth(lastMonthKey),
),
cumulativeSpend(ctx, ctx.user._id, lastYearKey, daysInMonth(lastYearKey)),
])

Expand Down
71 changes: 71 additions & 0 deletions convex/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,77 @@ export const recent = authedQuery({
},
})

/** Distinct merchant names from recent activity — enough to pick from, bounded. */
export const listMerchants = authedQuery({
args: {},
returns: v.array(v.string()),
handler: async (ctx) => {
const txs = await ctx.db
.query('transactions')
.withIndex('by_user_date', (q) => q.eq('userId', ctx.user._id))
.order('desc')
.take(400)

const names = new Set<string>()
for (const tx of txs) {
const name = (tx.merchantName ?? tx.originalDescription).trim()
if (name) names.add(name)
}
return [...names].sort((a, b) => a.localeCompare(b))
},
})

/** Mint a Flex category and file the charge in one write so a failed assign cannot orphan it. */
export const createAndAssignCategory = authedMutation({
args: {
transactionId: v.id('transactions'),
name: v.string(),
},
returns: v.id('categories'),
handler: async (ctx, args) => {
const tx = await ctx.db.get(args.transactionId)
if (!tx || tx.userId !== ctx.user._id) throw new Error('Not found')

const name = args.name.trim()
if (!name) throw new Error('Name is required')

const reused = await ctx.db
.query('categories')
.withIndex('by_user_name', (q) =>
q.eq('userId', ctx.user._id).eq('name', name),
)
.first()

let categoryId = reused?._id
let budgetType = reused?.budgetType
if (!categoryId) {
const existing = await ctx.db
.query('categories')
.withIndex('by_user', (q) => q.eq('userId', ctx.user._id))
.collect()
categoryId = await ctx.db.insert('categories', {
userId: ctx.user._id,
name,
icon: 'tag',
color: '#c27803',
isSystem: false,
budgetType: 'flex',
excludeFromBudget: false,
sortOrder: existing.length,
})
budgetType = 'flex'
}

await ctx.db.patch(args.transactionId, {
categoryId,
categorySource: 'user',
isTransfer: budgetType === 'transfer',
})

return categoryId
},
})

export const updateCategory = authedMutation({
args: {
transactionId: v.id('transactions'),
Expand Down
6 changes: 1 addition & 5 deletions src/components/chart-hover-tip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@ export function ChartHoverTip({
const flip = y < 56
const left = Math.min(Math.max(x, 72), window.innerWidth - 72)
const lines =
rows && rows.length > 0
? rows
: value != null
? [{ value, detail }]
: []
rows && rows.length > 0 ? rows : value != null ? [{ value, detail }] : []

return createPortal(
<div
Expand Down
143 changes: 143 additions & 0 deletions src/components/search-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { CheckIcon, Plus } from 'lucide-react'
import { useState } from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '#/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '#/components/ui/popover'
import { cn } from '#/lib/utils'

export type SearchSelectOption = {
value: string
label: string
keywords?: string
icon?: React.ReactNode
muted?: boolean
indent?: boolean
}

/**
* A ledger-row select: the trigger is the current label, the menu is a
* searchable list that can mint a new value from whatever you typed.
*/
export function SearchSelect({
value,
options,
onSelect,
onCreate,
placeholder = 'Select',
searchPlaceholder = 'Search…',
emptyText = 'Nothing matches.',
createLabel,
disabled,
align = 'start',
className,
children,
'aria-label': ariaLabel,
}: {
value?: string
options: Array<SearchSelectOption>
onSelect: (value: string) => void
onCreate?: (name: string) => void
placeholder?: string
searchPlaceholder?: string
emptyText?: string
createLabel?: (query: string) => string
disabled?: boolean
align?: 'start' | 'center' | 'end'
className?: string
children?: React.ReactNode
'aria-label': string
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')

const trimmed = query.trim()
const canCreate =
!!onCreate &&
trimmed.length > 0 &&
!options.some((o) => o.label.toLowerCase() === trimmed.toLowerCase())

const close = () => {
setOpen(false)
setQuery('')
}

return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
}}
>
<PopoverTrigger
disabled={disabled}
aria-label={ariaLabel}
className={cn('inline-pick', className)}
>
{children ?? (
<span className="truncate text-muted-foreground">{placeholder}</span>
)}
</PopoverTrigger>
<PopoverContent align={align} className="w-64 p-0 shadow-none">
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={query}
onValueChange={setQuery}
/>
<CommandList>
{canCreate ? null : <CommandEmpty>{emptyText}</CommandEmpty>}
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.keywords ?? ''} ${opt.value}`}
className={cn(
'text-[13px]',
opt.indent && 'pl-6',
opt.muted && 'text-muted-foreground',
)}
onSelect={() => {
if (opt.value !== value) onSelect(opt.value)
close()
}}
>
{opt.icon}
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
{opt.value === value ? (
<CheckIcon className="size-3.5 text-muted-foreground" />
) : null}
</CommandItem>
))}
</CommandGroup>
{canCreate ? (
<CommandGroup className="border-t border-border/70">
<CommandItem
value={`${trimmed} create`}
className="text-[13px] text-[var(--sea-ink)]"
onSelect={() => {
onCreate(trimmed)
close()
}}
>
<Plus className="size-3.5 text-muted-foreground" />
{createLabel?.(trimmed) ?? `Create “${trimmed}”`}
</CommandItem>
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
7 changes: 5 additions & 2 deletions src/components/spending-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ function areaPath(points: Array<Point>, axisDays: number, max: number): string {
const line = linePath(points, axisDays, max)
const last = points[points.length - 1]
const first = points[0]
if (!last || !first) return ''
return `${line} L ${dayX(last.day, axisDays)} ${H} L ${dayX(first.day, axisDays)} ${H} Z`
}

Expand Down Expand Up @@ -203,7 +202,11 @@ export function SpendingChart({
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--lagoon)" stopOpacity="0.28" />
<stop
offset="0%"
stopColor="var(--lagoon)"
stopOpacity="0.28"
/>
<stop offset="100%" stopColor="var(--lagoon)" stopOpacity="0" />
</linearGradient>
</defs>
Expand Down
Loading
Loading