Skip to main content

Learning Goals Overview

Learning goals in Sunschool are reward-based incentives created by parents to motivate learners. Goals encourage sustained engagement by letting learners save points toward tangible rewards.
Goals are different from achievements. Achievements are earned automatically for milestones. Goals are parent-defined rewards that learners save points toward.

Parent-Set Goals

Parents create custom reward goals tailored to their child’s interests and family values.

Goal Structure

// From rewards-service.ts:119-148
export async function createReward(
  parentId: number,
  data: {
    title: string;
    description?: string;
    tokenCost: number;
    category?: string;
    maxRedemptions?: number | null;
    imageEmoji?: string;
    color?: string;
  }
) {
  const { rows } = await pool.query(
    `INSERT INTO rewards
       (parent_id, title, description, token_cost, category, max_redemptions, image_emoji, color)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
     RETURNING *`,
    [
      parentId,
      data.title,
      data.description ?? null,
      data.tokenCost,
      data.category ?? 'GENERAL',
      data.maxRedemptions ?? null,
      data.imageEmoji ?? '🎁',
      data.color ?? '#4A90D9',
    ]
  );
  return toReward(rows[0]);
}

Goal Fields

FieldTypeDescriptionExample
titlestringShort goal name”Ice Cream Trip”
descriptionstringOptional details”Trip to favorite ice cream shop”
tokenCostnumberPoints needed50
categorystringType of reward”TREAT”, “ACTIVITY”, “TOY”
maxRedemptionsnumberLimit on times earned5 (or null for unlimited)
imageEmojistringVisual icon”🍦“
colorstringBrand color”#FF6B9D”

Goal Categories

Common parent-defined categories:

TREATS

Food rewards like ice cream, pizza, or favorite snacks

ACTIVITIES

Experiences like movie night, park visit, or game time

TOYS

Physical items like toys, books, or art supplies

PRIVILEGES

Special permissions like extra screen time or staying up late

Tracking Goal Progress

Learners see their progress toward each goal in real-time.

Goals Dashboard Strip

The learner home shows a preview of their top goal:
// From learner-home.tsx:35-78
const GoalsStrip: React.FC<{ learnerId?: number; onPress: () => void; theme: any }> = ({ learnerId, onPress, theme }) => {
  const { data: goals = [] } = _useQuery<any[]>({
    queryKey: ['/api/rewards-summary', learnerId],
    queryFn: () => apiRequest('GET', `/api/rewards-summary?learnerId=${learnerId}`).then(r => r.data),
    enabled: !!learnerId,
  });
  if (!goals.length) return null;
  const topGoal = goals[0];
  return (
    <TouchableOpacity onPress={onPress} style={{ /* ... */ }}>
      <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
        <Gift size={16} color={theme.colors.primary} />
        <Text style={{ marginLeft: 6, fontWeight: '700' }}>My Goals</Text>
        <Text style={{ fontSize: 12, color: theme.colors.primary }}>See all</Text>
      </View>
      <View style={{ flexDirection: 'row', alignItems: 'center' }}>
        <Text style={{ fontSize: 24, marginRight: 8 }}>{topGoal.imageEmoji}</Text>
        <View style={{ flex: 1 }}>
          <Text style={{ fontSize: 13, fontWeight: '600' }}>
            {topGoal.title}
          </Text>
          <View style={{ height: 8, backgroundColor: theme.colors.divider, borderRadius: 4 }}>
            <View style={{ width: `${topGoal.progressPct}%`, backgroundColor: topGoal.color }} />
          </View>
          <Text style={{ fontSize: 11, color: theme.colors.textSecondary }}>
            {topGoal.savedPoints}/{topGoal.tokenCost} pts · {topGoal.progressPct}%
          </Text>
        </View>
      </View>
      {goals.length > 1 && (
        <Text style={{ fontSize: 11, color: theme.colors.textSecondary }}>
          +{goals.length - 1} more goal{goals.length > 2 ? 's' : ''}
        </Text>
      )}
    </TouchableOpacity>
  );
};

Full Goals Page

Tapping “See all” or the goals strip opens the dedicated goals page:
// From learner-goals-page.tsx:40-114
const GoalCard: React.FC<{
  goal: RewardGoal;
  learnerId: number;
  onSavePoints: (rewardId: string) => void;
  onRedeem: (rewardId: string) => void;
  isProcessing: boolean;
}> = ({ 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 */}
      <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>

      {/* Actions */}
      <View style={styles.cardActions}>
        {!isComplete && !goal.hasPendingRedemption && (
          <TouchableOpacity
            style={styles.actionBtn}
            onPress={() => onSavePoints(goal.id)}>
            <Text style={styles.actionBtnText}>💰 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>
  );
};

Progress Calculation

// From rewards-service.ts:587-616
export async function getRewardSummaryForLearner(learnerId: number) {
  const { rows } = await pool.query(
    `SELECT r.id, r.title, r.token_cost, r.image_emoji, r.color,
       COALESCE(gs.saved_points, 0) AS saved_points,
       COALESCE(rr_pending.cnt, 0) AS pending_redemptions
     FROM rewards r
     JOIN users u ON u.id = $1
     LEFT JOIN reward_goal_savings gs
       ON gs.reward_id = r.id AND gs.learner_id = $1
     LEFT JOIN (
       SELECT reward_id, COUNT(*) AS cnt
       FROM reward_redemptions
       WHERE learner_id=$1 AND status='PENDING'
       GROUP BY reward_id
     ) rr_pending ON rr_pending.reward_id = r.id
     WHERE r.parent_id = u.parent_id AND r.is_active = TRUE
     ORDER BY r.token_cost ASC`,
    [learnerId]
  );
  return rows.map(row => ({
    id: row.id,
    title: row.title,
    tokenCost: row.token_cost,
    imageEmoji: row.image_emoji ?? '🎁',
    color: row.color ?? '#4A90D9',
    savedPoints: row.saved_points ?? 0,
    hasPendingRedemption: row.pending_redemptions > 0,
    progressPct: Math.min(100, Math.round(((row.saved_points ?? 0) / row.token_cost) * 100)),
  }));
}
Progress is calculated as:
progressPct = Math.min(100, Math.round((savedPoints / tokenCost) * 100))
Capped at 100% even if learner saves extra points.

Goal Completion Rewards

When a learner saves enough points for a goal, they can redeem it.

Redemption Flow

1

Goal Reaches 100%

Saved points ≥ token costUI Changes:
  • “Save Points” button becomes ”🎉 Cash Out!”
  • “Ready!” badge appears on goal card
  • Button changes to goal’s brand color
2

Learner Requests Redemption

Taps “Cash Out!” button
// 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] });
  },
});
3

Request Enters Pending State

Status: PENDINGLearner sees: ”⏳ Waiting for parent approval…”Goal card shows: “Waiting…” badge in orange
4

Parent Reviews Request

Parent sees notification in their dashboardOptions:
  • Approve with optional notes
  • Reject with explanation
5

Approval: Points Reset

If approved:
  • Saved points for that goal reset to 0
  • Learner can earn the same reward again
  • Parent delivers the actual reward
// From rewards-service.ts:460-465
// Deduct saved_points (reset to 0 so they can earn again)
await client.query(
  `UPDATE reward_goal_savings SET saved_points=0, updated_at=NOW()
   WHERE learner_id=$1 AND reward_id=$2`,
  [r.learner_id, r.reward_id]
);

Repeatable Rewards

Goals are repeatable by default:
// From rewards-service.ts:467-476
// Auto-deactivate if max redemptions reached
const ur = updatedReward[0];
if (ur?.max_redemptions && ur.current_redemptions >= ur.max_redemptions) {
  await client.query(
    `UPDATE rewards SET is_active = FALSE, updated_at=NOW() WHERE id=$1`,
    [r.reward_id]
  );
  // Refund any other learners' savings for this now-deactivated reward
  await refundSavingsForReward(client, r.reward_id);
}
Repeatable System:
  • After approval, saved points reset to 0
  • Goal remains active (unless max redemptions reached)
  • Learner can start saving again immediately
  • Parents can set maxRedemptions to limit times earned

Motivation System

Goals use psychological principles to motivate sustained learning.

Visual Progress

Every goal shows:
  1. Color-coded progress bar matching the goal’s brand color
  2. Percentage complete prominently displayed
  3. Absolute progress (e.g., “23/50 pts”)
  4. Emoji icon for visual appeal

Immediate Feedback

After each quiz:
// From quiz-page.tsx:241-279
<Modal visible={showDelegation && !!quizScore && rewardGoals.length > 0}>
  <View style={styles.delegationOverlay}>
    <View style={styles.delegationBox}>
      <Text style={styles.delegationTitle}>
        🎉 You earned {quizScore?.pointsAwarded ?? 0} pts!
      </Text>
      <Text style={styles.delegationSub}>
        Save them toward a reward goal:
      </Text>
      <ScrollView style={{ maxHeight: 240 }}>
        {rewardGoals.filter((g: any) => g.isActive).map((g: any) => {
          const pct = Math.min(100, Math.round(((g.savedPoints ?? 0) / g.tokenCost) * 100));
          return (
            <TouchableOpacity key={g.id} 
              onPress={() => { 
                saveDelegation(g.id, quizScore!.pointsAwarded); 
                setShowDelegation(false); 
              }}>
              <Text>{g.imageEmoji}</Text>
              <View>
                <Text>{g.title}</Text>
                <View style={styles.delegationTrack}>
                  <View style={[styles.delegationFill, { width: `${pct}%`, backgroundColor: g.color }]} />
                </View>
                <Text>{g.savedPoints}/{g.tokenCost}</Text>
              </View>
            </TouchableOpacity>
          );
        })}
      </ScrollView>
    </View>
  </View>
</Modal>
Immediately after completing a quiz, learners can delegate their new points to a goal while the accomplishment is fresh in their mind.

Goal Refunds

If parents delete or deactivate a goal, saved points are refunded:
// From rewards-service.ts:68-101
async function refundSavingsForReward(client: any, rewardId: string) {
  const { rows: savings } = await client.query(
    `SELECT learner_id, saved_points FROM reward_goal_savings
     WHERE reward_id = $1 AND saved_points > 0`,
    [rewardId]
  );

  for (const s of savings) {
    // Credit points back to learner balance
    await client.query(
      `UPDATE learner_points
       SET current_balance = current_balance + $1, last_updated = NOW()
       WHERE learner_id = $2`,
      [s.saved_points, s.learner_id]
    );

    // Record ledger entry for the refund
    await client.query(
      `INSERT INTO points_ledger (learner_id, amount, source_type, source_id, description)
       VALUES ($1, $2, 'GOAL_REFUND', $3, 'Reward removed — saved points refunded')`,
      [s.learner_id, s.saved_points, rewardId]
    );

    // Zero out the savings
    await client.query(
      `UPDATE reward_goal_savings SET saved_points = 0, updated_at = NOW()
       WHERE learner_id = $1 AND reward_id = $2`,
      [s.learner_id, rewardId]
    );
  }

  return savings.length;
}
Refund Policy: When goals are deleted or deactivated, all saved points are automatically returned to learners’ general balances with a ledger entry for transparency.

Empty State

If no goals exist:
// 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>
Encourages learners to communicate with parents about desired rewards.

Next Steps