Learning Goals System
While parents create and manage rewards, children interact with them as learning goals they can work toward. This page explains the learner-facing goal system from a parent’s perspective.
How Goals Work
From the learner’s perspective:
View available rewards in the Goals page
Save points toward rewards they want
Track progress with visual progress bars
Cash out when they have enough points saved
Wait for approval from the parent
From learner-goals-page.tsx:201-265:
const LearnerGoalsPage : React . FC = () => {
const { selectedLearner } = useMode ();
const learnerId = selectedLearner ?. id ?? 0 ;
const [ tab , setTab ] = useState < 'goals' | 'history' >( 'goals' );
const { data : goals = [] } = useQuery < RewardGoal []>({
queryKey: [ '/api/rewards' , learnerId ],
queryFn : () => apiRequest ( 'GET' , `/api/rewards?learnerId= ${ learnerId } ` ). then ( r => r . data ),
enabled: !! learnerId ,
});
const { data : balanceData } = useQuery ({
queryKey: [ '/api/points/balance' , learnerId ],
queryFn : () => apiRequest ( 'GET' , `/api/points/balance?learnerId= ${ learnerId } ` ). then ( r => r . data ),
enabled: !! learnerId ,
});
};
The Learner Interface
From learner-goals-page.tsx:242-252:
< View style = {styles. header } >
< TouchableOpacity onPress = {() => setLocation ( '/learner' )} >
< ArrowLeft size = { 22 } color = {theme.colors. textPrimary } />
</ TouchableOpacity >
< Gift size = { 20 } color = {theme.colors. primary } />
< Text style = {styles. headerTitle } > My Goals </ Text >
< View style = {{ flex : 1 }} />
< View style = {styles. balanceBadge } >
< Text style = {styles. balanceText } > ⭐ { balance } pts </ Text >
</ View >
</ View >
The balance shown includes only available points, not points already saved toward other goals.
Goal Cards
Each goal displays comprehensive information:
From learner-goals-page.tsx:40-114:
const GoalCard : React . FC <{ goal : RewardGoal }> = ({ goal , learnerId , onSavePoints , onRedeem , isProcessing }) => {
const saved = goal . savedPoints ?? 0 ;
const pct = goal . tokenCost > 0 ? Math . min ( 100 , Math . round (( saved / goal . tokenCost ) * 100 )) : 0 ;
const isComplete = saved >= goal . tokenCost ;
return (
< View style = { [styles.card, { borderLeftColor: goal.color, borderLeftWidth: 5 }]}>
{ /* Header with emoji and title */ }
<View style={styles.cardHeader}>
<View style={[styles.emojiCircle, { backgroundColor: goal.color + '22' }]}>
<Text style={{ fontSize: 32 }} > {goal. imageEmoji } </ Text >
</ View >
< View style = {{ flex : 1 , marginLeft : 12 }} >
< Text style = {styles. cardTitle } > {goal. title } </ Text >
{ goal . description && (
< Text style = {styles. cardDesc } > {goal. description } </ Text >
)}
</ View >
{ isComplete && ! goal . hasPendingRedemption && (
< View style = { [styles.readyBadge, { backgroundColor: goal.color }]}>
<Text style={styles.readyBadgeText}>Ready!</Text>
</View>
)}
</View>
{ /* Progress bar */ }
<View style={styles.progressSection}>
<View style={styles.progressLabelRow}>
<Text style={styles.progressLabel}>
{saved} / {goal.tokenCost} pts saved
</Text>
<Text style={[styles.progressPct, { color: goal.color }]}>{pct}%</Text>
</View>
<View style={[styles.progressTrack, { backgroundColor: theme.colors.divider }]}>
<View style={[styles.progressFill, { width: ` ${ pct } %` , backgroundColor: goal.color }]} />
</View>
</View>
{ /* Action buttons */ }
<View style={styles.cardActions}>
{!isComplete && !goal.hasPendingRedemption && (
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: goal.color + '20' , borderColor: goal.color }]}
onPress={() => onSavePoints(goal.id)}
>
<Text style={[styles.actionBtnText, { color: goal.color }]}>💰 Save Points</Text>
</TouchableOpacity>
)}
{isComplete && !goal.hasPendingRedemption && (
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: goal.color }]}
onPress={() => onRedeem(goal.id)}
>
<Text style={[styles.actionBtnText, { color: '#fff' }]}>🎉 Cash Out!</Text>
</TouchableOpacity>
)}
</View>
</View>
);
};
Saving Points
The Save Points Modal
When a child clicks “Save Points”, they see a modal to choose how many points to commit:
From learner-goals-page.tsx:116-197:
const SavePointsModal : React . FC <{ goal : RewardGoal | null ; balance : number }> = ({
goal , balance , learnerId , visible , onClose
}) => {
const [ points , setPoints ] = useState ( 0 );
const saveMutation = useMutation ({
mutationFn : ( pts : number ) =>
apiRequest ( 'POST' , `/api/rewards/ ${ goal ! . id } /save?learnerId= ${ learnerId } ` , { points: pts })
. then ( r => r . data ),
onSuccess : () => {
queryClient . invalidateQueries ({ queryKey: [ '/api/rewards' ] });
queryClient . invalidateQueries ({ queryKey: [ '/api/points/balance' ] });
onClose ();
},
});
if ( ! goal ) return null ;
const saved = goal . savedPoints ?? 0 ;
const remaining = Math . max ( 0 , goal . tokenCost - saved );
const maxSave = Math . min ( balance , remaining );
const presets = [ 1 , 5 , 10 , maxSave ]. filter (( v , i , a ) => v > 0 && a . indexOf ( v ) === i );
return (
< Modal visible = { visible } transparent onRequestClose = { onClose } >
< View style = {styles. modalBox } >
< Text style = {styles. modalTitle } >
Save to { goal . imageEmoji } { goal . title }
</ Text >
< Text style = {styles. modalSub } >
Balance : { balance } pts · { remaining > 0 ? `Need ${ remaining } more` : '✓ Goal reached!' }
</ Text >
{ /* Preset buttons */ }
< View style = {styles. presetRow } >
{ presets . map ( p => (
< TouchableOpacity key = { p }
style = { [styles.presetBtn, points === p && { backgroundColor: goal.color }]}
onPress={() => setPoints(p)}
>
<Text style={[styles.presetBtnText, points === p && { color: '#fff' }]}>{p}</Text>
</TouchableOpacity>
))}
</View>
{ /* Custom stepper */ }
<View style={styles.customRow}>
<TouchableOpacity onPress={() => setPoints(Math.max(0, points - 1))}>
<Text>−</Text>
</TouchableOpacity>
<Text style={[styles.pointsDisplay, { color: goal.color }]}>{points}</Text>
<TouchableOpacity onPress={() => setPoints(Math.min(maxSave, points + 1))}>
<Text>+</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.modalBtn, { backgroundColor: goal.color }]}
onPress={() => saveMutation.mutate(points)}
disabled={points <= 0}
>
<Text>Save {points} pts</Text>
</TouchableOpacity>
</View>
</Modal>
);
};
How Point Saving Works
Child Selects Amount
Uses preset buttons (1, 5, 10, or max) or custom stepper The maximum they can save is: const maxSave = Math . min ( balance , remaining );
Whichever is less: available balance or points still needed
Points Transfer
When they click “Save”:
Points deducted from available balance
Points added to saved total for that reward
Progress bar updates immediately
Balance badge updates
Working Toward Goal
Child continues earning and saving until: const isComplete = saved >= goal . tokenCost ;
When complete, the “Cash Out” button appears
Points saved toward a goal are locked to that goal. Children can’t use them elsewhere unless the parent deletes the reward (which refunds the points) or they redeem it.
Cashing Out (Redemption)
When a child has enough points saved:
From learner-goals-page.tsx:227-234:
const redeemMutation = useMutation ({
mutationFn : ( rewardId : string ) =>
apiRequest ( 'POST' , `/api/rewards/ ${ rewardId } /redeem?learnerId= ${ learnerId } ` ). then ( r => r . data ),
onSuccess : () => {
queryClient . invalidateQueries ({ queryKey: [ '/api/rewards' , learnerId ] });
queryClient . invalidateQueries ({ queryKey: [ '/api/redemptions/my' , learnerId ] });
},
});
Click Cash Out
The child clicks the ”🎉 Cash Out!” button on the goal card
Create Redemption Request
System creates a redemption with status PENDING
Wait for Parent
The goal card shows: { goal . hasPendingRedemption && (
< View style = {styles. readyBadge } >
< Text > Waiting … </ Text >
</ View >
)}
And a message: < Text >⏳ Waiting for parent approval … </ Text >
Parent Approves/Rejects
From the parent Rewards page, you can:
Approve - Points are spent, child receives reward
Reject - Points refunded to available balance
Redemption History
Children can view their past redemptions in the History tab:
From learner-goals-page.tsx:292-328:
{ tab === 'history' && (
<>
{redemptions. length === 0 ? (
< View style = {styles. empty } >
< Text style = {{ fontSize : 52 }} > 📭 </ Text >
< Text style = {styles. emptyTitle } > No history yet </ Text >
< Text style = {styles. emptyDesc } >
Cash out your first reward to see it here !
</ Text >
</ View >
) : redemptions . map ( r => (
< View key = {r. id } style = { [styles.historyCard, { borderLeftColor: r.rewardColor }]}>
<View style={styles.cardHeader}>
<Text style={{ fontSize: 28 }} > {r. rewardEmoji } </ Text >
< View style = {{ flex : 1 }} >
< Text style = {styles. cardTitle } > {r. rewardTitle } </ Text >
< Text style = {styles. cardDesc } >
{ r . tokensSpent } pts · { new Date ( r . requestedAt ). toLocaleDateString ()}
</ Text >
</ View >
< View style = { [styles.statusBadge, {
backgroundColor: r.status === 'APPROVED' ? '#E8F5E9' :
r.status === 'REJECTED' ? '#FFEBEE' : '#FFF8E1'
}]}>
<Text>
{r.status === 'APPROVED' ? '✓ Approved' :
r . status === 'REJECTED' ? '✗ Rejected' : '⏳ Pending' }
</ Text >
</ View >
</ View >
{ r . parentNotes && (
< Text style = {styles. cardDesc } >
Parent : { r . parentNotes }
</ Text >
)}
</ View >
))}
</>
)}
Parent Notes
When approving or rejecting redemptions, parents can include notes that children see in their history:
{ r . parentNotes && (
< Text > Parent : {r. parentNotes } </ Text >
)}
Example notes:
“Great job! We’ll go this weekend.”
“Let’s wait until after your homework is done.”
“Pick which game you want and we’ll get it.”
Understanding the Two-Balance System
Sunschool uses two separate point balances:
Available Balance Points the child can spend freely:
Save toward any goal
Use for other features
Shown in the header badge
Saved (Locked) Points Points committed to specific goals:
Locked to that reward
Count toward goal progress
Can’t be used elsewhere
From the API:
// Total points earned
const totalPoints = /* sum of all point transactions */ ;
// Points saved toward goals
const savedPoints = /* sum of points saved to rewards */ ;
// Available balance
const balance = totalPoints - savedPoints ;
Goal States
Goals progress through several states:
In Progress
! isComplete && ! hasPendingRedemption
Shows ”💰 Save Points” button
Complete
isComplete && ! hasPendingRedemption
Shows ”🎉 Cash Out!” button with “Ready!” badge
Pending Approval
Shows “Waiting…” badge and waiting message
Approved
Appears in history as approved, points spent
Rejected
Points refunded, goal returns to previous state
Empty States
If no goals are available:
From learner-goals-page.tsx:272-278:
{ goals . length === 0 ? (
< View style = {styles. empty } >
< Text style = {{ fontSize : 52 }} > 🎁 </ Text >
< Text style = {styles. emptyTitle } > No goals yet !</ Text >
< Text style = {styles. emptyDesc } >
Ask your parent to add some rewards for you to earn .
</ Text >
</ View >
) : (
// Show goal cards
)}
Best Practices for Parents
Ensure point costs are reasonable for your child’s engagement level. A child who earns 5-10 points per day shouldn’t face 500-point goals.
When children request redemptions, respond within a day or two. This keeps them motivated and trusting the system.
Even when rejecting (rare cases), include a kind explanation:
“Let’s save this for after your test”
“Great saving! Let’s do this on the weekend”
Watch the progress tracking
Use the learner progress feature on reward cards to see what motivates each child. Some might focus on one big goal, others spread points across many.
When a child reaches 50% or 75% toward a goal, acknowledge their progress. This builds momentum.
Troubleshooting
Child says points disappeared
Check if they:
Saved points toward a goal (shows in progress bar)
Had a redemption approved (spent the points)
Tried to save more than available (would fail silently)
Can't cash out despite having enough
Verify:
Points are in “saved” balance, not available
The goal isn’t inactive
There isn’t already a pending redemption
Page has been refreshed
Goal doesn't show progress
Ensure:
Points were actually saved (check available balance decreased)
The save mutation succeeded (no errors)
Page was refreshed after saving
Next Steps