}
/>
}
/>
}
/>
diff --git a/app/dashboard/goals/page.tsx b/app/dashboard/goals/page.tsx
index 69ec500..197ea10 100644
--- a/app/dashboard/goals/page.tsx
+++ b/app/dashboard/goals/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { useState, useMemo } from 'react'
import {
GraduationCap,
HeartPulse,
@@ -10,9 +10,13 @@ import {
import PageHeader from '@/components/PageHeader'
import SavingsGoalCard from '@/components/Dashboard/SavingsGoalCard'
import SavingsGoalsStatsCards from './components/SavingsGoalsStatsCards'
+import SavingsGoalModal from './components/SavingsGoalModal'
+import { SavingsGoal } from './types'
+import { calculateDaysLeft, checkIsOverdue } from './utils'
+import { useClientTranslator } from '@/lib/i18n/client'
// Sample data matching Figma design
-const goalsData = [
+const initialGoals: SavingsGoal[] = [
{
id: 1,
title: "Children's Education",
@@ -21,9 +25,7 @@ const goalsData = [
iconGradient: { from: '#DC2626', to: '#B91C1C' },
currentAmount: 3600,
targetAmount: 5000,
- targetDate: 'Dec 31, 2025',
- daysLeft: 335,
- isOverdue: false,
+ targetDate: '2026-12-31',
},
{
id: 2,
@@ -33,9 +35,7 @@ const goalsData = [
iconGradient: { from: '#F87171', to: '#EF4444' },
currentAmount: 1800,
targetAmount: 2000,
- targetDate: 'Mar 15, 2025',
- daysLeft: 44,
- isOverdue: false,
+ targetDate: '2026-08-15',
},
{
id: 3,
@@ -45,8 +45,7 @@ const goalsData = [
iconGradient: { from: '#DC2626', to: '#B91C1C' },
currentAmount: 8500,
targetAmount: 25000,
- targetDate: 'Jan 15, 2025',
- isOverdue: true,
+ targetDate: '2026-05-15', // This will be overdue as of June 17, 2026
},
{
id: 4,
@@ -56,36 +55,56 @@ const goalsData = [
iconGradient: { from: '#F87171', to: '#EF4444' },
currentAmount: 3000,
targetAmount: 3000,
- targetDate: 'Jul 1, 2025',
- isOverdue: false,
+ targetDate: '2026-07-01',
},
]
-// Calculate summary stats
-const totalGoals = goalsData.length
-const totalTarget = goalsData.reduce((sum, goal) => sum + goal.targetAmount, 0)
-const totalSaved = goalsData.reduce((sum, goal) => sum + goal.currentAmount, 0)
+export default function SavingsGoalsPage() {
+ const { t } = useClientTranslator()
+ const [goals, setGoals] = useState
(initialGoals)
+ const [showModal, setShowModal] = useState(false)
+ const [editingGoal, setEditingGoal] = useState(null)
-function formatCurrency(amount: number): string {
- return new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(amount)
-}
+ // Calculate summary stats dynamically
+ const stats = useMemo(() => {
+ const totalGoals = goals.length
+ const totalTarget = goals.reduce((sum, goal) => sum + goal.targetAmount, 0)
+ const totalSaved = goals.reduce((sum, goal) => sum + goal.currentAmount, 0)
+ return { totalGoals, totalTarget, totalSaved }
+ }, [goals])
-export default function SavingsGoalsPage() {
- const [showNewGoalModal, setShowNewGoalModal] = useState(false)
+ const handleNewGoal = () => {
+ setEditingGoal(null)
+ setShowModal(true)
+ }
+
+ const handleEditGoal = (goal: SavingsGoal) => {
+ setEditingGoal(goal)
+ setShowModal(true)
+ }
+
+ const handleSaveGoal = (goalData: Partial) => {
+ if (editingGoal) {
+ setGoals(goals.map(g => g.id === editingGoal.id ? { ...g, ...goalData } as SavingsGoal : g))
+ } else {
+ const newGoal: SavingsGoal = {
+ ...goalData,
+ id: Date.now(),
+ currentAmount: 0,
+ } as SavingsGoal
+ setGoals([...goals, newGoal])
+ }
+ setShowModal(false)
+ }
return (
{/* Header */}
setShowNewGoalModal(true)}
+ title={t('savingsGoals.title')}
+ subtitle={t('savingsGoals.subtitle')}
+ ctaLabel={t('savingsGoals.newGoal')}
+ onCtaClick={handleNewGoal}
showBottomDivider
/>
@@ -93,9 +112,9 @@ export default function SavingsGoalsPage() {
{/* Savings Goals Stats Cards */}
@@ -105,7 +124,7 @@ export default function SavingsGoalsPage() {
{/* Goals Grid */}
- {goalsData.map((goal) => (
+ {goals.map((goal) => (
console.log('Add funds to', goal.title)}
- onDetails={() => console.log('View details for', goal.title)}
+ onEdit={() => handleEditGoal(goal)}
/>
))}
- {/* New Goal Modal Placeholder */}
- {showNewGoalModal && (
- setShowNewGoalModal(false)}
- >
-
e.stopPropagation()}
- >
-
Create New Goal
-
- Goal creation form will be implemented here. Connect to savings_goals smart contract.
-
-
setShowNewGoalModal(false)}
- className="touch-target-wide w-full rounded-[14px] text-sm 375:text-base font-semibold text-white"
- style={{
- background: 'linear-gradient(180deg, #DC2626 0%, #B91C1C 100%)',
- }}
- >
- Close
-
-
-
- )}
+ {/* Savings Goal Modal */}
+ setShowModal(false)}
+ onSave={handleSaveGoal}
+ editingGoal={editingGoal}
+ />
)
}
+
diff --git a/app/dashboard/goals/types.ts b/app/dashboard/goals/types.ts
new file mode 100644
index 0000000..c8f42db
--- /dev/null
+++ b/app/dashboard/goals/types.ts
@@ -0,0 +1,20 @@
+import { ReactNode } from 'react'
+
+export interface SavingsGoal {
+ id: number | string
+ title: string
+ description: string
+ icon: ReactNode
+ iconGradient: { from: string; to: string }
+ currentAmount: number
+ targetAmount: number
+ targetDate: string
+}
+
+export interface SavingsGoalFormData {
+ title: string
+ description: string
+ targetAmount: number
+ targetDate: string
+ iconType: 'education' | 'medical' | 'home' | 'vacation'
+}
diff --git a/app/dashboard/goals/utils.ts b/app/dashboard/goals/utils.ts
new file mode 100644
index 0000000..e8f58b3
--- /dev/null
+++ b/app/dashboard/goals/utils.ts
@@ -0,0 +1,31 @@
+/**
+ * Calculates the number of days left until a target date.
+ * Returns 0 if the date is today or in the past.
+ */
+export function calculateDaysLeft(targetDate: string): number {
+ const target = new Date(targetDate)
+ const today = new Date()
+
+ // Set times to midnight for accurate day calculation
+ target.setHours(0, 0, 0, 0)
+ today.setHours(0, 0, 0, 0)
+
+ const diffTime = target.getTime() - today.getTime()
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+
+ return Math.max(0, diffDays)
+}
+
+/**
+ * Checks if a target date is in the past (overdue).
+ */
+export function checkIsOverdue(targetDate: string): boolean {
+ const target = new Date(targetDate)
+ const today = new Date()
+
+ // Set times to midnight
+ target.setHours(0, 0, 0, 0)
+ today.setHours(0, 0, 0, 0)
+
+ return target < today
+}
diff --git a/app/dashboard/insight/page.tsx b/app/dashboard/insight/page.tsx
index 89eaf63..2aa1632 100644
--- a/app/dashboard/insight/page.tsx
+++ b/app/dashboard/insight/page.tsx
@@ -1,4 +1,4 @@
-import SixMonthTrendsWidget from '@/components/dashboard/SixMonthTrendsWidget'
+import SixMonthTrendsWidget from '@/components/Dashboard/SixMonthTrendsWidget'
export default function InsightPage() {
return (
{
} finally {
setLoading(false);
}
- }, [statusFilter]);
+ }, [statusFilter, t]);
useEffect(() => {
fetchTransactions(undefined, true);
diff --git a/app/family/components/FamilyMemberStatCard.tsx b/app/family/components/FamilyMemberStatCard.tsx
index b2aea5c..96ed74f 100644
--- a/app/family/components/FamilyMemberStatCard.tsx
+++ b/app/family/components/FamilyMemberStatCard.tsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
-import { Copy, Check, Eye, Edit2, User, Send, ShieldCheck } from "lucide-react";
+import { Copy, Check, Eye, Edit2, User, Send, ShieldCheck, Save, X } from "lucide-react";
+import { validateSpendingLimit } from "@/lib/validation/family-limits";
export type FamilyMemberRole = "Recipient" | "Sender" | "Admin";
@@ -28,6 +29,9 @@ const FamilyMemberStatCard: React.FC
= ({
member,
}) => {
const [copied, setCopied] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [newLimit, setNewLimit] = useState(member.spendingLimit);
+ const [error, setError] = useState(null);
const getRoleMeta = (role: FamilyMemberRole) => {
switch (role) {
@@ -105,6 +109,17 @@ const FamilyMemberStatCard: React.FC = ({
}
};
+ const handleSaveLimit = () => {
+ const validation = validateSpendingLimit(newLimit);
+ if (!validation.isValid) {
+ setError(validation.error || 'Invalid limit');
+ return;
+ }
+ setError(null);
+ setIsEditing(false);
+ // TODO: Call contract update function here
+ };
+
const roleMeta = getRoleMeta(member.role);
const usageMeta = getUsageMeta(member.usedPercentage);
const remaining = member.spendingLimit - member.used;
@@ -187,9 +202,19 @@ const FamilyMemberStatCard: React.FC = ({
Spending limit
-
- {currencyFormatter.format(member.spendingLimit)}
-
+ {isEditing ? (
+ setNewLimit(parseFloat(e.target.value))}
+ className="mt-2 w-full rounded-lg border border-white/10 bg-[#1a1a1a] px-2 py-1 text-white focus:border-red-500 focus:outline-none"
+ />
+ ) : (
+
+ {currencyFormatter.format(member.spendingLimit)}
+
+ )}
+ {error && {error}
}
@@ -215,7 +240,7 @@ const FamilyMemberStatCard: React.FC = ({
{usageMeta.helper}
-
+
@@ -231,24 +256,35 @@ const FamilyMemberStatCard: React.FC
= ({
+ className='flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm font-medium text-gray-300 transition-colors hover:bg-white/[0.08]'>
View Details
-
-
- Edit Limits
-
+ {isEditing ? (
+ <>
+
+
+ Save
+
+ {setIsEditing(false); setError(null);}}
+ className='flex flex-1 items-center justify-center gap-2 rounded-xl border border-gray-500/20 bg-gray-500/10 px-4 py-3 text-sm font-medium text-gray-100 transition-colors hover:bg-gray-500/20'>
+
+ Cancel
+
+ >
+ ) : (
+ setIsEditing(true)}
+ className='flex flex-1 items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm font-medium text-red-50 transition-colors hover:bg-red-500/20'>
+
+ Edit Limits
+
+ )}
-
-
- Member actions will unlock after the wallet contract integration is
- connected.
-
);
};
diff --git a/app/insurance/page.tsx b/app/insurance/page.tsx
index eec4c19..e86d186 100644
--- a/app/insurance/page.tsx
+++ b/app/insurance/page.tsx
@@ -1,143 +1,21 @@
"use client"
import Link from 'next/link'
import { ArrowLeft, Plus, Shield, Loader2, CalendarClock } from 'lucide-react'
-import PrimaryButton from '@/components/ui/PrimaryButton'
import { ActionState } from '@/lib/auth/middleware';
import { useFormAction } from '@/lib/hooks/useFormAction';
import { getPolicyPaymentPresentation } from '@/lib/ui/status-semantics';
import NewPolicyForm from '@/components/forms/NewPolicyForm';
export default function Insurance() {
-
- type AddInsuranceResponse = ActionState & { policyName?: string; coverageAmount?: number, monthlyPremium?: number, coverageType?: string };
+ type AddInsuranceResponse = ActionState & {
+ policyName?: string;
+ coverageAmount?: number;
+ monthlyPremium?: number;
+ coverageType?: string
+ };
const [state, formAction, pending] = useFormAction("/api/insurance");
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
Micro-Insurance
-
-
-
- New Policy
-
-
Policy creation will be available once contract integration is live.
-
-
-
-
- {/* New Policy Form */}
-
-
-
- {/* Active Policies */}
-
-
Active Policies
-
-function PolicyCard({ name, coverageType, monthlyPremium, coverageAmount, nextPayment, active }: {
- name: string,
- coverageType: string,
- monthlyPremium: number,
- coverageAmount: number,
- nextPayment: string,
- active: boolean
-}) {
- const paymentStatus = getPolicyPaymentPresentation(nextPayment, active);
- const StatusIcon = paymentStatus.icon;
-
- return (
-
-
-
-
-
{name}
-
-
-
- {paymentStatus.label}
-
-
-
-
-
- Coverage Type
- {coverageType}
-
-
- Monthly Premium
- ${monthlyPremium}
-
-
- Coverage Amount
- ${coverageAmount}
-
-
- Next Payment
- {nextPayment}
-
-
-
-
-
-
-
{paymentStatus.label}
-
{paymentStatus.emphasis}
-
-
- Next scheduled payment: {nextPayment}
-
-
-
-
-
- {active && (
-
- Pay Premium Now
-"use client"
-import Link from 'next/link'
-import { ArrowLeft, Plus, Shield, Loader2, CalendarClock } from 'lucide-react'
-import PrimaryButton from '@/components/ui/PrimaryButton'
-import { ActionState } from '@/lib/auth/middleware';
-import { useFormAction } from '@/lib/hooks/useFormAction';
-import { getPolicyPaymentPresentation } from '@/lib/ui/status-semantics';
-import NewPolicyForm from '@/components/forms/NewPolicyForm';
-
-export default function Insurance() {
-
- type AddInsuranceResponse = ActionState & { policyName?: string; coverageAmount?: number, monthlyPremium?: number, coverageType?: string };
-
- const [state, formAction, pending] = useFormAction("/api/insurance");
return (
{/* Header */}
@@ -150,14 +28,16 @@ export default function Insurance() {
Micro-Insurance
-
-
- New Policy
-
- Policy creation will be available once contract integration is live.
+
+
+
+ New Policy
+
+
Policy creation will be available once contract integration is live.
+
@@ -193,13 +73,20 @@ export default function Insurance() {
)
}
-function PolicyCard({ name, coverageType, monthlyPremium, coverageAmount, nextPayment, active }: {
- name: string,
- coverageType: string,
- monthlyPremium: number,
- coverageAmount: number,
- nextPayment: string,
- active: boolean
+function PolicyCard({
+ name,
+ coverageType,
+ monthlyPremium,
+ coverageAmount,
+ nextPayment,
+ active
+}: {
+ name: string;
+ coverageType: string;
+ monthlyPremium: number;
+ coverageAmount: number;
+ nextPayment: string;
+ active: boolean;
}) {
const paymentStatus = getPolicyPaymentPresentation(nextPayment, active);
const StatusIcon = paymentStatus.icon;
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
index d60f4d0..f5e64f4 100644
--- a/app/settings/page.tsx
+++ b/app/settings/page.tsx
@@ -16,6 +16,7 @@ import {
Smartphone,
} from "lucide-react";
import { useDensity } from "@/lib/context/DensityContext";
+import { useToast } from "@/lib/context/ToastContext";
const SECTIONS = [
diff --git a/app/tutorial/[tutorialId]/chapter/[chapterId]/page.tsx b/app/tutorial/[tutorialId]/chapter/[chapterId]/page.tsx
index a4715ee..7afe852 100644
--- a/app/tutorial/[tutorialId]/chapter/[chapterId]/page.tsx
+++ b/app/tutorial/[tutorialId]/chapter/[chapterId]/page.tsx
@@ -1,11 +1,11 @@
import ChapterView from "../../../../../components/tutorials/ChapterView";
type Props = {
- params: { tutorialId: string; chapterId: string };
+ params: Promise<{ tutorialId: string; chapterId: string }>;
};
-export default function TutorialChapterPage({ params }: Props) {
- const { tutorialId, chapterId } = params;
+export default async function TutorialChapterPage({ params }: Props) {
+ const { tutorialId, chapterId } = await params;
const chapterIndex = parseInt(chapterId, 10) || 0;
// In a full implementation you'd fetch chapter data here.
diff --git a/app/tutorial/[tutorialId]/page.tsx b/app/tutorial/[tutorialId]/page.tsx
index 846efae..386018a 100644
--- a/app/tutorial/[tutorialId]/page.tsx
+++ b/app/tutorial/[tutorialId]/page.tsx
@@ -1,10 +1,11 @@
import Link from "next/link";
type Props = {
- params: { tutorialId: string };
+ params: Promise<{ tutorialId: string }>;
};
-export default function TutorialOverviewPage({ params }: Props) {
+export default async function TutorialOverviewPage({ params }: Props) {
+ const { tutorialId } = await params;
const chapters = Array.from({ length: 5 }).map((_, i) => ({
id: String(i),
title: `Chapter ${i + 1}`,
@@ -30,7 +31,7 @@ export default function TutorialOverviewPage({ params }: Props) {
Back to tutorials
- {params.tutorialId}
+ {tutorialId}
Continue your learning path and resume the next available chapter when you’re ready.
@@ -40,7 +41,7 @@ export default function TutorialOverviewPage({ params }: Props) {
Overall progress
{overallProgress}%
Resume chapter {resumeChapter + 1}
@@ -64,7 +65,7 @@ export default function TutorialOverviewPage({ params }: Props) {
return (
{
const bills = billsByStatus[status] ?? [];
if (!bills.length) return null;
- const headerStyles = {
+ const headerStyles: Record = {
overdue: "text-red-400",
urgent: "text-amber-400",
upcoming: "text-white/60",
+ paid: "text-green-400",
};
return (
diff --git a/components/Dashboard/SavingsGoalCard.tsx b/components/Dashboard/SavingsGoalCard.tsx
index 02c9431..cc8550d 100644
--- a/components/Dashboard/SavingsGoalCard.tsx
+++ b/components/Dashboard/SavingsGoalCard.tsx
@@ -9,6 +9,7 @@ import {
Clock3,
Sparkles,
} from 'lucide-react'
+import { useClientTranslator } from '@/lib/i18n/client'
export interface SavingsGoalCardProps {
title: string
@@ -21,7 +22,7 @@ export interface SavingsGoalCardProps {
daysLeft?: number
isOverdue?: boolean
onAddFunds?: () => void
- onDetails?: () => void
+ onEdit?: () => void
}
function formatCurrency(amount: number): string {
@@ -52,8 +53,9 @@ export default function SavingsGoalCard({
daysLeft,
isOverdue = false,
onAddFunds,
- onDetails,
+ onEdit,
}: SavingsGoalCardProps) {
+ const { t } = useClientTranslator()
const percentage = targetAmount > 0 ? Math.min((currentAmount / targetAmount) * 100, 100) : 0
const remaining = Math.max(targetAmount - currentAmount, 0)
const isComplete = !isOverdue && percentage >= 100
@@ -69,28 +71,28 @@ export default function SavingsGoalCard({
const statusStyles = {
overdue: {
- label: 'Overdue',
+ label: t('savingsGoals.card.overdue'),
icon: AlertTriangle,
background: 'rgba(220, 38, 38, 0.18)',
border: 'rgba(220, 38, 38, 0.35)',
text: '#FCA5A5',
},
complete: {
- label: 'Completed',
+ label: t('savingsGoals.card.complete'),
icon: CheckCircle2,
background: 'rgba(16, 185, 129, 0.16)',
border: 'rgba(34, 197, 94, 0.28)',
text: '#A7F3D0',
},
'near-complete': {
- label: 'Almost there',
+ label: t('savingsGoals.card.nearComplete'),
icon: Sparkles,
background: 'rgba(245, 158, 11, 0.18)',
border: 'rgba(245, 158, 11, 0.3)',
text: '#FCD34D',
},
'on-track': {
- label: 'On track',
+ label: t('savingsGoals.card.onTrack'),
icon: Clock3,
background: 'rgba(56, 189, 248, 0.18)',
border: 'rgba(56, 189, 248, 0.3)',
@@ -99,12 +101,12 @@ export default function SavingsGoalCard({
}[state]
const targetInfoLabel = isOverdue
- ? 'Overdue'
+ ? t('savingsGoals.card.overdue')
: isComplete
- ? 'Goal met'
+ ? t('savingsGoals.card.goalMet')
: daysLeft !== undefined
- ? `${daysLeft} days left`
- : 'No deadline'
+ ? t('savingsGoals.card.daysLeft', { count: daysLeft })
+ : t('savingsGoals.card.noDeadline')
return (
-
{title}
-
{description}
+
{title}
+
{description}
@@ -160,12 +162,12 @@ export default function SavingsGoalCard({
{isComplete
- ? 'All set — target reached'
- : `Need ${formatCurrency(remaining)} more`}
+ ? t('savingsGoals.card.complete')
+ : t('savingsGoals.card.needMore', { amount: formatCurrency(remaining) })}
-
Target
+
{t('savingsGoals.card.target')}
{formatDate(targetDate)}
{targetInfoLabel}
@@ -193,7 +195,7 @@ export default function SavingsGoalCard({
{percentage.toFixed(0)}% complete
- {isComplete ? 'Goal reached' : `${formatCurrency(remaining)} remaining`}
+ {isComplete ? t('savingsGoals.card.goalMet') : t('savingsGoals.card.remaining', { amount: formatCurrency(remaining) })}
@@ -204,14 +206,14 @@ export default function SavingsGoalCard({
onClick={onAddFunds}
className="touch-target-wide rounded-[14px] bg-gradient-to-b from-[#DC2626] to-[#B91C1C] px-4 py-3 text-sm font-semibold text-white transition hover:brightness-110"
>
- Add Funds
+ {t('savingsGoals.card.addFunds')}
- Details
+ {t('savingsGoals.card.edit')}
diff --git a/components/Insights/remittanceTrendChart.tsx b/components/Insights/remittanceTrendChart.tsx
index e1335d8..b57a90c 100644
--- a/components/Insights/remittanceTrendChart.tsx
+++ b/components/Insights/remittanceTrendChart.tsx
@@ -1,5 +1,16 @@
'use client'
+import { Activity } from 'lucide-react';
+import {
+ ResponsiveContainer,
+ AreaChart,
+ CartesianGrid,
+ XAxis,
+ YAxis,
+ Tooltip,
+ ReferenceLine,
+ Area
+} from 'recharts';
import { INSIGHTS_PALETTE } from './palette';
const LINE_COLOR = INSIGHTS_PALETTE[0];
@@ -30,7 +41,6 @@ export const MOCK_TREND_DATA: TrendDataPoint[] = [
const AXIS_COLOR = '#6b7280'
const GRID_COLOR = 'rgba(255,255,255,0.06)'
-const LINE_COLOR = '#D72323'
// ── Custom tooltip ────────────────────────────────────────────────────────────
interface CustomTooltipProps {
@@ -45,7 +55,7 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
return (
-
{label}
{label}
Amount
diff --git a/components/SessionExpiryNotification.tsx b/components/SessionExpiryNotification.tsx
index 1265bb3..a927339 100644
--- a/components/SessionExpiryNotification.tsx
+++ b/components/SessionExpiryNotification.tsx
@@ -30,7 +30,6 @@ export default function SessionExpiryNotification({
const primaryActionRef = useRef
(null);
const isWarning = phase === 'warning';
const isExpired = phase === 'expired';
- if (!isWarning && !isExpired) return null;
useEffect(() => {
if (phase !== 'none') {
@@ -48,6 +47,8 @@ export default function SessionExpiryNotification({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isWarning, isExpired, onDismiss]);
+ if (!isWarning && !isExpired) return null;
+
return (
-}
-
-function CustomLegend({ payload }: CustomLegendProps) {
- return (
-
- {payload?.map((entry, index) => (
-
- ))}
-
- )
-}
-
-interface SummaryCardProps {
- icon: React.ReactNode
- label: string
- value: string
- subtitle: string
- variant?: 'highlight' | 'default'
- valueColor?: string
-}
-
-function SummaryCard({ icon, label, value, subtitle, variant = 'default', valueColor }: SummaryCardProps) {
- const isHighlight = variant === 'highlight'
- return (
-
-
-
- {value}
-
-
{subtitle}
-
- )
-}
-
-interface SixMonthTrendsWidgetProps {
- /** Pass an empty array to show the empty state */
- chartData?: typeof mockChartData
- /** Pass true to show the error state */
- hasError?: boolean
-}
-
-export default function SixMonthTrendsWidget({
- chartData = mockChartData,
- hasError = false,
-}: SixMonthTrendsWidgetProps) {
- const [retryKey, setRetryKey] = useState(0)
- const handleRetry = useCallback(() => setRetryKey((k) => k + 1), [])
-
- const isEmpty = !hasError && chartData.length === 0
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
6-Month Trends
-
-
- Track your financial patterns
-
-
- {!isEmpty && !hasError && (
-
- View Details
-
- )}
-
-
- {hasError ? (
-
- ) : isEmpty ? (
-
- ) : (
- <>
- {/* Line Chart */}
-
-
-
-
-
- `$${value}`}
- domain={[0, 3400]}
- ticks={[0, 850, 1700, 2550, 3400]}
- width={45}
- />
- [`$${Number(value).toLocaleString()}`, '']}
- />
- } />
-
-
-
-
-
-
-
-
- {/* Summary Cards */}
-
-
- } label="Highest Month" value="Dec 2025" subtitle="$5,630 total" variant="highlight" />
- } label="Average" value="$5,395" subtitle="Per month" />
- } label="Growth" value="+15.7%" subtitle="vs. Jul 2025" valueColor="#DC2626" />
-
-
- >
- )}
-
- )
-}
diff --git a/lib/client/apiClient.ts b/lib/client/apiClient.ts
index de2b52c..f46b56a 100644
--- a/lib/client/apiClient.ts
+++ b/lib/client/apiClient.ts
@@ -1,6 +1,6 @@
/**
- * API client wrapper with session expiry detection
- * Automatically handles session expiry and redirects users to re-authenticate
+ * API client wrapper with session expiry detection and retry mechanism
+ * Automatically handles session expiry, redirects users to re-authenticate, and retries failed requests.
*
* @example Basic usage
* ```typescript
@@ -13,7 +13,9 @@
* ```typescript
* const data = await apiClient.post('/api/protected/action', {
* body: JSON.stringify({ key: 'value' }),
- * headers: { 'Content-Type': 'application/json' }
+ * headers: { 'Content-Type': 'application/json' },
+ * retries: 2,
+ * backoff: 500
* });
* ```
*/
@@ -21,18 +23,49 @@
import { sessionHandler } from './sessionHandler';
export interface ApiClientOptions extends RequestInit {
- // Additional options can be added here
+ retries?: number;
+ backoff?: number;
+}
+
+const DEFAULT_RETRIES = 3;
+const DEFAULT_BACKOFF = 1000;
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function fetchWithRetry(url: string, options: ApiClientOptions): Promise
{
+ const { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF, ...fetchOptions } = options;
+
+ try {
+ const response = await fetch(url, fetchOptions);
+
+ // Retry on 5xx errors or 429 rate limiting
+ if (response.status >= 500 || response.status === 429) {
+ if (retries > 0) {
+ await delay(backoff);
+ return fetchWithRetry(url, { ...options, retries: retries - 1, backoff: backoff * 2 });
+ }
+ }
+
+ return response;
+ } catch (error) {
+ // Retry on network errors
+ if (retries > 0) {
+ await delay(backoff);
+ return fetchWithRetry(url, { ...options, retries: retries - 1, backoff: backoff * 2 });
+ }
+ throw error;
+ }
}
/**
- * Make an API request with automatic session expiry handling
+ * Make an API request with automatic session expiry handling and retries
* @param url - The API endpoint URL
* @param options - Fetch options
* @returns Response object or null if session expired
*/
async function request(url: string, options?: ApiClientOptions): Promise {
try {
- const response = await fetch(url, options);
+ const response = await fetchWithRetry(url, options || {});
// Check if session expired
if (await sessionHandler.isSessionExpired(response)) {
diff --git a/lib/i18n/client.ts b/lib/i18n/client.ts
index 06e5531..08ff864 100644
--- a/lib/i18n/client.ts
+++ b/lib/i18n/client.ts
@@ -5,11 +5,12 @@ import en from "./locales/en.json";
import es from "./locales/es.json";
type SupportedLocale = "en" | "es";
-type TranslationTree = Record;
+type TranslationValue = string | { [key: string]: TranslationValue };
+type TranslationTree = Record;
const resources: Record = {
- en,
- es,
+ en: en as TranslationTree,
+ es: es as TranslationTree,
};
function resolveLocale(language: string | null | undefined): SupportedLocale {
@@ -18,7 +19,7 @@ function resolveLocale(language: string | null | undefined): SupportedLocale {
}
function readPath(tree: TranslationTree, path: string): string | undefined {
- return path.split(".").reduce((acc, key) => {
+ return path.split(".").reduce((acc, key) => {
if (!acc || typeof acc === "string") return undefined;
return acc[key];
}, tree) as string | undefined;
@@ -44,11 +45,18 @@ export function useClientTranslator(defaultLocale: SupportedLocale = "en") {
return {
locale,
- t: (path: string, fallback?: string) =>
- readPath(currentTree, path) ??
- readPath(fallbackTree, path) ??
- fallback ??
- path,
+ t: (path: string, options?: string | Record) => {
+ let text = readPath(currentTree, path) ??
+ readPath(fallbackTree, path) ??
+ (typeof options === 'string' ? options : path);
+
+ if (typeof options === 'object' && typeof text === 'string') {
+ Object.entries(options).forEach(([key, value]) => {
+ text = text.replace(new RegExp(`{{${key}}}`, 'g'), String(value));
+ });
+ }
+ return text;
+ }
};
}, [locale]);
}
diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json
index adfe367..3e73031 100644
--- a/lib/i18n/locales/en.json
+++ b/lib/i18n/locales/en.json
@@ -26,9 +26,56 @@
"failed_fetch_total_premium": "Failed to compute total premium",
"address_signature_required": "Address and signature are required",
"nonce_expired": "Nonce expired or missing. Please request a new nonce.",
- "signature_verification_failed": "Signature verification failed"
- },
- "transactionHistory": {
+ "signature_verification_failed": "Signature verification failed",
+ "goal_name_required": "Goal name is required",
+ "goal_id_required": "Goal ID is required",
+ "goal_description_invalid": "Invalid description",
+ "goal_name_too_long": "Goal name cannot exceed 100 characters",
+ "goal_description_too_long": "Goal description cannot exceed 200 characters",
+ "goal_amount_positive": "Amount must be positive",
+ "goal_date_future": "Target date must be in the future",
+ "goal_invalid_date": "Invalid date format"
+ },
+ "savingsGoals": {
+ "title": "Savings Goals",
+ "subtitle": "Track and achieve your financial dreams",
+ "newGoal": "New Goal",
+ "editGoal": "Edit Goal",
+ "createGoal": "Create Goal",
+ "modal": {
+ "createTitle": "Create New Goal",
+ "editTitle": "Edit Savings Goal",
+ "titleLabel": "Goal Title",
+ "titlePlaceholder": "e.g. Children's Education",
+ "descriptionLabel": "Description (Optional)",
+ "descriptionPlaceholder": "What are you saving for?",
+ "amountLabel": "Target Amount (USDC)",
+ "dateLabel": "Target Date",
+ "iconLabel": "Choose Icon"
+ },
+ "stats": {
+ "totalGoals": "Total Goals",
+ "totalTarget": "Total Target",
+ "totalSaved": "Total Saved"
+ },
+ "card": {
+ "addFunds": "Add Funds",
+ "edit": "Edit",
+ "target": "Target",
+ "remaining": "{{amount}} remaining",
+ "needMore": "Need {{amount}} more",
+ "complete": "All set — target reached",
+ "daysLeft": "{{count}} days left",
+ "overdue": "Overdue",
+ "goalMet": "Goal met",
+ "nearComplete": "Almost there",
+ "onTrack": "On track",
+ "noDeadline": "No deadline"
+ }
+
+ },
+ "transactionHistory": {
+
"title": "Transaction History",
"resultsCount": "{{count}} transactions found",
"resultsCountZero": "No transactions found",
diff --git a/lib/validation/family-limits.ts b/lib/validation/family-limits.ts
new file mode 100644
index 0000000..c29b8ea
--- /dev/null
+++ b/lib/validation/family-limits.ts
@@ -0,0 +1,21 @@
+// Validation utilities for family spending limits
+
+export interface ValidationResult {
+ isValid: boolean;
+ error?: string;
+}
+
+/**
+ * Validates that a spending limit is a non-negative, finite number
+ */
+export function validateSpendingLimit(limit: number): ValidationResult {
+ if (typeof limit !== 'number' || isNaN(limit) || !isFinite(limit)) {
+ return { isValid: false, error: 'Limit must be a valid number' };
+ }
+
+ if (limit < 0) {
+ return { isValid: false, error: 'Limit must be non-negative' };
+ }
+
+ return { isValid: true };
+}
diff --git a/lib/validation/savings-goals.ts b/lib/validation/savings-goals.ts
index 6130f61..3081819 100644
--- a/lib/validation/savings-goals.ts
+++ b/lib/validation/savings-goals.ts
@@ -9,20 +9,12 @@ export interface ValidationResult {
* Validates that an amount is a positive number
*/
export function validateAmount(amount: number): ValidationResult {
- if (typeof amount !== 'number') {
- return { isValid: false, error: 'Amount must be a number' };
- }
-
- if (isNaN(amount)) {
- return { isValid: false, error: 'Amount cannot be NaN' };
- }
-
- if (!isFinite(amount)) {
- return { isValid: false, error: 'Amount must be finite' };
+ if (typeof amount !== 'number' || isNaN(amount) || !isFinite(amount)) {
+ return { isValid: false, error: 'goal_amount_positive' };
}
if (amount <= 0) {
- return { isValid: false, error: 'Amount must be positive' };
+ return { isValid: false, error: 'goal_amount_positive' };
}
return { isValid: true };
@@ -33,19 +25,19 @@ export function validateAmount(amount: number): ValidationResult {
*/
export function validateFutureDate(dateString: string): ValidationResult {
if (!dateString || typeof dateString !== 'string') {
- return { isValid: false, error: 'Date must be a non-empty string' };
+ return { isValid: false, error: 'goal_invalid_date' };
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
- return { isValid: false, error: 'Invalid date format' };
+ return { isValid: false, error: 'goal_invalid_date' };
}
const now = new Date();
if (date <= now) {
- return { isValid: false, error: 'Target date must be in the future' };
+ return { isValid: false, error: 'goal_date_future' };
}
return { isValid: true };
@@ -55,12 +47,8 @@ export function validateFutureDate(dateString: string): ValidationResult {
* Validates that a goal ID is non-empty
*/
export function validateGoalId(goalId: string): ValidationResult {
- if (!goalId || typeof goalId !== 'string') {
- return { isValid: false, error: 'Goal ID must be a non-empty string' };
- }
-
- if (goalId.trim().length === 0) {
- return { isValid: false, error: 'Goal ID cannot be empty or whitespace' };
+ if (!goalId || typeof goalId !== 'string' || goalId.trim().length === 0) {
+ return { isValid: false, error: 'goal_id_required' };
}
return { isValid: true };
@@ -70,16 +58,27 @@ export function validateGoalId(goalId: string): ValidationResult {
* Validates that a goal name is valid
*/
export function validateGoalName(name: string): ValidationResult {
- if (!name || typeof name !== 'string') {
- return { isValid: false, error: 'Goal name must be a non-empty string' };
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
+ return { isValid: false, error: 'goal_name_required' };
}
- if (name.trim().length === 0) {
- return { isValid: false, error: 'Goal name cannot be empty or whitespace' };
+ if (name.length > 100) {
+ return { isValid: false, error: 'goal_name_too_long' };
}
- if (name.length > 100) {
- return { isValid: false, error: 'Goal name cannot exceed 100 characters' };
+ return { isValid: true };
+}
+
+/**
+ * Validates that a goal description is valid
+ */
+export function validateGoalDescription(description: string): ValidationResult {
+ if (typeof description !== 'string') {
+ return { isValid: false, error: 'goal_description_invalid' };
+ }
+
+ if (description.length > 200) {
+ return { isValid: false, error: 'goal_description_too_long' };
}
return { isValid: true };
diff --git a/lib/webhooks/retry.ts b/lib/webhooks/retry.ts
index 27c4bdb..7b4e5cc 100644
--- a/lib/webhooks/retry.ts
+++ b/lib/webhooks/retry.ts
@@ -59,7 +59,7 @@ export async function processPendingWebhooks(
);
const results = await Promise.allSettled(
- pendingEvents.map((event) => processEvent(event.id, event.source))
+ pendingEvents.map((event: any) => processEvent(event.id, event.source))
);
const failed = results.filter((r) => r.status === 'rejected').length;
diff --git a/tests/unit/apiClient.test.ts b/tests/unit/apiClient.test.ts
new file mode 100644
index 0000000..6402295
--- /dev/null
+++ b/tests/unit/apiClient.test.ts
@@ -0,0 +1,58 @@
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { apiClient } from '../../lib/client/apiClient';
+import { sessionHandler } from '../../lib/client/sessionHandler';
+
+// Mock sessionHandler
+vi.mock('../../lib/client/sessionHandler', () => ({
+ sessionHandler: {
+ isSessionExpired: vi.fn(),
+ handleSessionExpiry: vi.fn(),
+ }
+}));
+
+describe('apiClient', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ vi.clearAllMocks();
+ });
+
+ it('should retry on 500 error', async () => {
+ (fetch as any)
+ .mockResolvedValueOnce({ status: 500 })
+ .mockResolvedValueOnce({ status: 200, json: () => Promise.resolve({ data: 'ok' }) });
+
+ (sessionHandler.isSessionExpired as any).mockResolvedValue(false);
+
+ // Use a small backoff to make tests run fast
+ const response = await apiClient.get('/api/test', { retries: 1, backoff: 10 });
+
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(response?.status).toBe(200);
+ });
+
+ it('should retry on network error', async () => {
+ (fetch as any)
+ .mockRejectedValueOnce(new Error('Network error'))
+ .mockResolvedValueOnce({ status: 200, json: () => Promise.resolve({ data: 'ok' }) });
+
+ (sessionHandler.isSessionExpired as any).mockResolvedValue(false);
+
+ const response = await apiClient.get('/api/test', { retries: 1, backoff: 10 });
+
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(response?.status).toBe(200);
+ });
+
+ it('should not retry on 401 (session expired) as it is handled separately', async () => {
+ (fetch as any)
+ .mockResolvedValueOnce({ status: 401 });
+
+ (sessionHandler.isSessionExpired as any).mockResolvedValue(true);
+
+ const response = await apiClient.get('/api/test', { retries: 1, backoff: 10 });
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(response).toBeNull();
+ expect(sessionHandler.handleSessionExpiry).toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/goals/utils.test.ts b/tests/unit/goals/utils.test.ts
new file mode 100644
index 0000000..572f49a
--- /dev/null
+++ b/tests/unit/goals/utils.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { calculateDaysLeft, checkIsOverdue } from '@/app/dashboard/goals/utils'
+
+describe('Goals Utilities', () => {
+ beforeEach(() => {
+ // Mock today's date to Wednesday, June 17, 2026
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-17T12:00:00Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ describe('calculateDaysLeft', () => {
+ it('should calculate days left for a future date', () => {
+ // June 20, 2026 is 3 days after June 17
+ expect(calculateDaysLeft('2026-06-20')).toBe(3)
+ })
+
+ it('should return 0 for today', () => {
+ expect(calculateDaysLeft('2026-06-17')).toBe(0)
+ })
+
+ it('should return 0 for a past date', () => {
+ expect(calculateDaysLeft('2026-06-10')).toBe(0)
+ })
+
+ it('should handle end of year', () => {
+ // June 17, 2026 to Dec 31, 2026
+ // June: 13, July: 31, Aug: 31, Sep: 30, Oct: 31, Nov: 30, Dec: 31
+ // Total: 13+31+31+30+31+30+31 = 197
+ expect(calculateDaysLeft('2026-12-31')).toBe(197)
+ })
+ })
+
+ describe('checkIsOverdue', () => {
+ it('should return false for a future date', () => {
+ expect(checkIsOverdue('2026-06-20')).toBe(false)
+ })
+
+ it('should return false for today', () => {
+ expect(checkIsOverdue('2026-06-17')).toBe(false)
+ })
+
+ it('should return true for a past date', () => {
+ expect(checkIsOverdue('2026-06-10')).toBe(true)
+ })
+ })
+})
diff --git a/tests/unit/validation/family-limits.test.ts b/tests/unit/validation/family-limits.test.ts
new file mode 100644
index 0000000..030e9fa
--- /dev/null
+++ b/tests/unit/validation/family-limits.test.ts
@@ -0,0 +1,25 @@
+import { validateSpendingLimit } from './family-limits';
+
+describe('validateSpendingLimit', () => {
+ it('should return isValid: true for valid positive numbers', () => {
+ expect(validateSpendingLimit(100).isValid).toBe(true);
+ expect(validateSpendingLimit(0.01).isValid).toBe(true);
+ });
+
+ it('should return isValid: true for 0', () => {
+ expect(validateSpendingLimit(0).isValid).toBe(true);
+ });
+
+ it('should return isValid: false for negative numbers', () => {
+ const result = validateSpendingLimit(-100);
+ expect(result.isValid).toBe(false);
+ expect(result.error).toBe('Limit must be non-negative');
+ });
+
+ it('should return isValid: false for non-numeric values', () => {
+ // @ts-ignore
+ expect(validateSpendingLimit('100').isValid).toBe(false);
+ // @ts-ignore
+ expect(validateSpendingLimit(NaN).isValid).toBe(false);
+ });
+});
diff --git a/tests/unit/validation/savings-goals.test.ts b/tests/unit/validation/savings-goals.test.ts
index 17c552f..057e2f6 100644
--- a/tests/unit/validation/savings-goals.test.ts
+++ b/tests/unit/validation/savings-goals.test.ts
@@ -5,6 +5,7 @@ import {
validateFutureDate,
validateGoalId,
validateGoalName,
+ validateGoalDescription,
validatePublicKey,
} from '@/lib/validation/savings-goals';
@@ -20,37 +21,37 @@ describe('Validation Functions - Unit Tests', () => {
it('should reject zero', () => {
const result = validateAmount(0);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Amount must be positive');
+ expect(result.error).toBe('goal_amount_positive');
});
it('should reject negative numbers', () => {
const result = validateAmount(-1);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Amount must be positive');
+ expect(result.error).toBe('goal_amount_positive');
});
it('should reject NaN', () => {
const result = validateAmount(NaN);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Amount cannot be NaN');
+ expect(result.error).toBe('goal_amount_positive');
});
it('should reject Infinity', () => {
const result = validateAmount(Infinity);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Amount must be finite');
+ expect(result.error).toBe('goal_amount_positive');
});
it('should reject negative Infinity', () => {
const result = validateAmount(-Infinity);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Amount must be finite');
+ expect(result.error).toBe('goal_amount_positive');
});
it('should reject non-number types', () => {
const result = validateAmount('100' as any);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Amount must be a number');
+ expect(result.error).toBe('goal_amount_positive');
});
});
@@ -68,7 +69,7 @@ describe('Validation Functions - Unit Tests', () => {
const result = validateFutureDate(pastDate.toISOString());
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Target date must be in the future');
+ expect(result.error).toBe('goal_date_future');
});
it('should reject current date/time', () => {
@@ -76,25 +77,25 @@ describe('Validation Functions - Unit Tests', () => {
const result = validateFutureDate(now.toISOString());
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Target date must be in the future');
+ expect(result.error).toBe('goal_date_future');
});
it('should reject invalid date strings', () => {
const result = validateFutureDate('not-a-date');
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Invalid date format');
+ expect(result.error).toBe('goal_invalid_date');
});
it('should reject empty strings', () => {
const result = validateFutureDate('');
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Date must be a non-empty string');
+ expect(result.error).toBe('goal_invalid_date');
});
it('should reject non-string types', () => {
const result = validateFutureDate(123 as any);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Date must be a non-empty string');
+ expect(result.error).toBe('goal_invalid_date');
});
});
@@ -108,19 +109,19 @@ describe('Validation Functions - Unit Tests', () => {
it('should reject empty strings', () => {
const result = validateGoalId('');
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal ID must be a non-empty string');
+ expect(result.error).toBe('goal_id_required');
});
it('should reject whitespace-only strings', () => {
const result = validateGoalId(' ');
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal ID cannot be empty or whitespace');
+ expect(result.error).toBe('goal_id_required');
});
it('should reject non-string types', () => {
const result = validateGoalId(123 as any);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal ID must be a non-empty string');
+ expect(result.error).toBe('goal_id_required');
});
});
@@ -134,25 +135,45 @@ describe('Validation Functions - Unit Tests', () => {
it('should reject empty strings', () => {
const result = validateGoalName('');
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal name must be a non-empty string');
+ expect(result.error).toBe('goal_name_required');
});
it('should reject whitespace-only strings', () => {
const result = validateGoalName(' ');
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal name cannot be empty or whitespace');
+ expect(result.error).toBe('goal_name_required');
});
it('should reject names longer than 100 characters', () => {
const result = validateGoalName('A'.repeat(101));
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal name cannot exceed 100 characters');
+ expect(result.error).toBe('goal_name_too_long');
});
it('should reject non-string types', () => {
const result = validateGoalName(123 as any);
expect(result.isValid).toBe(false);
- expect(result.error).toBe('Goal name must be a non-empty string');
+ expect(result.error).toBe('goal_name_required');
+ });
+ });
+
+ describe('validateGoalDescription', () => {
+ it('should accept valid descriptions', () => {
+ expect(validateGoalDescription('Saving for a new car').isValid).toBe(true);
+ expect(validateGoalDescription('').isValid).toBe(true);
+ expect(validateGoalDescription('A'.repeat(200)).isValid).toBe(true);
+ });
+
+ it('should reject descriptions longer than 200 characters', () => {
+ const result = validateGoalDescription('A'.repeat(201));
+ expect(result.isValid).toBe(false);
+ expect(result.error).toBe('goal_description_too_long');
+ });
+
+ it('should reject non-string types', () => {
+ const result = validateGoalDescription(123 as any);
+ expect(result.isValid).toBe(false);
+ expect(result.error).toBe('goal_description_invalid');
});
});