Skip to content

Commit 40487d2

Browse files
MetalSyntaxclaude
andcommitted
feat: invoice OCR digitization (3 engines) and gamification v2
- Add three selectable OCR engines (Tesseract, PaddleOCR-local, Azure Document Intelligence) behind a shared packages/core dispatcher, wired into Add Transaction's scan flow, a new Settings > Dev Mode > Digitize Invoices batch tool, and a per-receipt quick-digitize action from Transactions > Invoices. - Add gamification v2: coins/cosmetics shop, daily XP goal, budget health, personal league, perfect-day streaks, a financial path, and a weekly recap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e38fa6b commit 40487d2

43 files changed

Lines changed: 3843 additions & 643 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/App.tsx

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ScheduledPaymentView } from './views/ScheduledPaymentView';
1616
import { TransactionsListView } from './views/TransactionsListView';
1717
import { CalendarHeatmapView } from './views/CalendarHeatmapView';
1818
import { CurrencyPerformanceView } from './views/CurrencyPerformanceView';
19+
import { InvoiceDigitizeView } from './views/settings/InvoiceDigitizeView';
1920
import { ScheduledNotificationsView } from './views/ScheduledNotificationsView';
2021
import { ShoppingListView } from './views/ShoppingListView';
2122
import { PeopleView } from './views/PeopleView';
@@ -31,7 +32,8 @@ import { XPToastContainer } from './components/gamification/XPToast';
3132
import { LevelUpModal } from './components/gamification/LevelUpModal';
3233
import { BadgeEarnedSheet } from './components/gamification/BadgeEarnedSheet';
3334
import { ThemeProvider } from './contexts/ThemeContext';
34-
import type { BadgeDefinition, LevelId, GamificationProfile } from '@parity/core';
35+
import type { BadgeDefinition, LevelId, GamificationProfile, DailyGoalTier } from '@parity/core';
36+
import { createDefaultProfile, toISODate, CHALLENGE_TEMPLATES, offsetDate } from '@parity/core';
3537
import { getTranslation } from '@parity/i18n';
3638
import { motion, AnimatePresence } from 'framer-motion';
3739
import './index.css';
@@ -223,7 +225,7 @@ function AppContent() {
223225
const { data: ratesData, refetch: fetchAllRates } = useExchangeRates(isLoaded);
224226

225227
// Gamification engine
226-
const { profile: gamProfile, dispatchEvent: dispatchGamEvent, pendingRewards, clearRewards } = useGamification();
228+
const { profile: gamProfile, dispatchEvent: dispatchGamEvent, pendingRewards, clearRewards, buyShopItem, equipOutfit, rerollChallenge, updateDailyGoal } = useGamification();
227229
const [xpToasts, setXpToasts] = useState<{ xpGained: number; label: string }[]>([]);
228230
const [levelUpLevel, setLevelUpLevel] = useState<LevelId | null>(null);
229231
const [badgesToShow, setBadgesToShow] = useState<BadgeDefinition[]>([]);
@@ -1255,7 +1257,15 @@ function AppContent() {
12551257
}
12561258
};
12571259

1258-
const handleStartFresh = (data: { profile: Partial<UserProfile>; displayCurrency: Currency; isBalanceVisible: boolean; navbarFavorites: string[] }) => {
1260+
const handleStartFresh = (data: {
1261+
profile: Partial<UserProfile>;
1262+
displayCurrency: Currency;
1263+
isBalanceVisible: boolean;
1264+
navbarFavorites: string[];
1265+
dailyGoal: DailyGoalTier;
1266+
hasDebtsSeed: boolean;
1267+
attentionGoal: 'saving' | 'budgeting' | 'logging';
1268+
}) => {
12591269
setUserProfile(prev => ({ ...prev, ...data.profile }));
12601270
setDisplayCurrency(data.displayCurrency);
12611271
localStorage.setItem("displayCurrency", data.displayCurrency);
@@ -1276,6 +1286,61 @@ function AppContent() {
12761286
icon: 'wallet',
12771287
};
12781288
setAccounts([initialWallet]);
1289+
1290+
// Create customized initial gamification profile
1291+
const initialGamProfile = createDefaultProfile();
1292+
initialGamProfile.dailyGoal = data.dailyGoal;
1293+
initialGamProfile.placementCompletedAt = new Date().toISOString();
1294+
1295+
// Seed initial challenges based on attentionGoal and hasDebtsSeed
1296+
const today = toISODate(new Date());
1297+
const matchedTemplates: string[] = [];
1298+
if (data.attentionGoal === 'saving') {
1299+
matchedTemplates.push('daily_log_today', 'weekly_save_25', 'monthly_goal_progress');
1300+
} else if (data.attentionGoal === 'budgeting') {
1301+
matchedTemplates.push('daily_log_today', 'weekly_control_food', 'monthly_all_green');
1302+
} else {
1303+
matchedTemplates.push('daily_log_today', 'weekly_no_gaps', 'monthly_goal_progress');
1304+
}
1305+
1306+
if (data.hasDebtsSeed) {
1307+
matchedTemplates.push('weekly_settle_debt');
1308+
}
1309+
1310+
for (const tid of matchedTemplates) {
1311+
const temp = CHALLENGE_TEMPLATES.find(t => t.id === tid);
1312+
if (temp) {
1313+
let targetVal = 1;
1314+
const target = temp.target;
1315+
if (target) {
1316+
if ('count' in target) targetVal = target.count;
1317+
else if ('amount' in target) targetVal = target.amount;
1318+
else if ('limit' in target) targetVal = target.limit;
1319+
else if ('envelopeCount' in target) targetVal = target.envelopeCount;
1320+
else if ('days' in target) targetVal = target.days;
1321+
else if ('progressPercent' in target) targetVal = target.progressPercent;
1322+
}
1323+
1324+
const expiresAt = temp.horizon === 'daily'
1325+
? today + 'T23:59:59.999Z'
1326+
: temp.horizon === 'weekly'
1327+
? offsetDate(today, 7) + 'T23:59:59.999Z'
1328+
: offsetDate(today, 30) + 'T23:59:59.999Z';
1329+
1330+
initialGamProfile.challengeProgress[temp.id] = {
1331+
challengeId: temp.id,
1332+
assignedAt: today + 'T00:00:00.000Z',
1333+
expiresAt,
1334+
current: 0,
1335+
target: targetVal,
1336+
completed: false,
1337+
completedAt: null,
1338+
xpClaimed: false,
1339+
};
1340+
}
1341+
}
1342+
1343+
idbService.saveGamificationProfile(initialGamProfile);
12791344
setIsFirstTime(false);
12801345
};
12811346

@@ -1660,6 +1725,9 @@ function AppContent() {
16601725
onUpdateTransaction={(tx) => setTransactions(prev => prev.map(t => t.id === tx.id ? tx : t))}
16611726
initialViewMode={currentView === 'INVOICES' ? 'INVOICES' : 'LIST'}
16621727
showConfirm={showConfirm}
1728+
userProfile={userProfile}
1729+
isDevMode={isDevMode}
1730+
showAlert={showAlert}
16631731
/>
16641732
)}
16651733
{currentView === 'HEATMAP' && (
@@ -1687,6 +1755,15 @@ function AppContent() {
16871755
isDevMode={isDevMode}
16881756
/>
16891757
)}
1758+
{currentView === 'INVOICE_DIGITIZE' && (
1759+
<InvoiceDigitizeView
1760+
onBack={() => setCurrentView('PROFILE')}
1761+
transactions={filteredTransactions}
1762+
onUpdateTransaction={(tx) => setTransactions(prev => prev.map(t => t.id === tx.id ? tx : t))}
1763+
userProfile={userProfile}
1764+
showAlert={showAlert}
1765+
/>
1766+
)}
16901767
{currentView === 'SCHEDULED_NOTIFICATIONS' && (
16911768
<ScheduledNotificationsView
16921769
onBack={() => setCurrentView('PROFILE')}
@@ -1779,6 +1856,10 @@ function AppContent() {
17791856
<AcademyView
17801857
profile={gamProfile}
17811858
onBack={() => setCurrentView('DASHBOARD')}
1859+
onBuyItem={buyShopItem}
1860+
onEquipOutfit={equipOutfit}
1861+
onRerollChallenge={rerollChallenge}
1862+
onUpdateDailyGoal={updateDailyGoal}
17821863
t={t}
17831864
/>
17841865
)}
@@ -1819,7 +1900,7 @@ function AppContent() {
18191900

18201901
{/* Bottom Nav (Only visible on Dashboard and Wallet/Profile root) */}
18211902
{['DASHBOARD', 'WALLET', 'PROFILE', 'ANALYSIS', 'TRANSACTIONS', 'BUDGET', 'SCHEDULED', 'HEATMAP', 'CURRENCY_PERF', 'SHOPPING_LIST', 'CONTACTS', 'DEBT_TRACKER', 'EXPORT', 'FIN_CALENDAR', 'IMPORT', 'PDF_REPORT', 'SCENARIO_PLANNER'].includes(currentView) && isNavVisible && !showAdd && !showSettings && (
1822-
<div className="h-20 bg-theme-surface/95 backdrop-blur-md border-t border-white/5 flex items-center justify-center gap-4 md:gap-24 px-2 relative z-10 pb-2 flex-shrink-0 w-full transition-all duration-300 animate-in slide-in-from-bottom-full">
1903+
<div className="h-20 bg-theme-surface/95 backdrop-blur-md border-t border-white/5 flex items-center justify-center gap-0 md:gap-24 px-2 relative z-10 pb-2 flex-shrink-0 w-full transition-all duration-300 animate-in slide-in-from-bottom-full">
18231904
{(() => {
18241905
const NAV_ICON_MAP: Record<string, React.ReactNode> = {
18251906
DASHBOARD: <Home size={24} />,
@@ -1934,6 +2015,8 @@ function AppContent() {
19342015
} as Transaction : null)}
19352016
showAlert={showAlert}
19362017
contacts={contacts}
2018+
isDevMode={isDevMode}
2019+
userProfile={userProfile}
19372020
/>
19382021
)}
19392022

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import {
2+
createDefaultProfile,
3+
processEvent,
4+
evaluateBadges,
5+
CHALLENGE_TEMPLATES,
6+
BADGE_DEFINITIONS,
7+
GamificationProfile
8+
} from '@parity/core';
9+
import type { GamificationEvent } from '@parity/core';
10+
11+
describe('Gamification Engine — Automated Badges & Challenges Tests', () => {
12+
let profile: GamificationProfile;
13+
const today = '2026-07-02';
14+
15+
beforeEach(() => {
16+
profile = createDefaultProfile();
17+
});
18+
19+
describe('Badge Attainment Conditions', () => {
20+
it('should award first_dollar badge on transaction_created event', () => {
21+
const event: GamificationEvent = {
22+
type: 'transaction_created',
23+
timestamp: new Date().toISOString(),
24+
};
25+
const earned = evaluateBadges(profile, event, []);
26+
const badge = earned.find(b => b.id === 'first_dollar');
27+
expect(badge).toBeDefined();
28+
expect(badge?.xp).toBe(25);
29+
});
30+
31+
it('should award multi_wallet badge when wallet currency count is three_or_more', () => {
32+
const event: GamificationEvent = {
33+
type: 'transaction_created',
34+
timestamp: new Date().toISOString(),
35+
payload: { walletCount: 3, currency: 'three_or_more' }
36+
};
37+
const earned = evaluateBadges(profile, event, []);
38+
const badge = earned.find(b => b.id === 'multi_wallet');
39+
expect(badge).toBeDefined();
40+
});
41+
42+
it('should award digital_fortress badge when composite drive sync and PWA installs are met', () => {
43+
const event1: GamificationEvent = {
44+
type: 'pwa_installed',
45+
timestamp: new Date().toISOString(),
46+
};
47+
const result = processEvent(profile, event1, [], today);
48+
49+
const event2: GamificationEvent = {
50+
type: 'drive_sync_enabled',
51+
timestamp: new Date().toISOString(),
52+
};
53+
const result2 = processEvent(result.updatedProfile, event2, [], today);
54+
const earned = result2.newBadges.find(b => b.id === 'digital_fortress');
55+
expect(earned).toBeDefined();
56+
});
57+
58+
it('should award goal_crusher badge when completed goals is at least 1', () => {
59+
profile.stats.totalGoalsCompleted = 1;
60+
const event: GamificationEvent = {
61+
type: 'transaction_created',
62+
timestamp: new Date().toISOString(),
63+
};
64+
const earned = evaluateBadges(profile, event, []);
65+
expect(earned.find(b => b.id === 'goal_crusher')).toBeDefined();
66+
});
67+
68+
it('should award debt_slayer badge when debt payments completed is at least 1', () => {
69+
profile.stats.totalDebtsSettled = 1;
70+
const event: GamificationEvent = {
71+
type: 'transaction_created',
72+
timestamp: new Date().toISOString(),
73+
};
74+
const earned = evaluateBadges(profile, event, []);
75+
expect(earned.find(b => b.id === 'debt_slayer')).toBeDefined();
76+
});
77+
78+
it('should award on_fire badge when streak is at least 7 days', () => {
79+
profile.streak.currentDays = 7;
80+
const event: GamificationEvent = {
81+
type: 'transaction_created',
82+
timestamp: new Date().toISOString(),
83+
};
84+
const earned = evaluateBadges(profile, event, []);
85+
expect(earned.find(b => b.id === 'on_fire')).toBeDefined();
86+
});
87+
88+
it('should award rate_watcher badge on rate_watch_7_days event', () => {
89+
const event: GamificationEvent = {
90+
type: 'rate_watch_7_days',
91+
timestamp: new Date().toISOString(),
92+
};
93+
const earned = evaluateBadges(profile, event, []);
94+
expect(earned.find(b => b.id === 'rate_watcher')).toBeDefined();
95+
});
96+
});
97+
98+
describe('Challenge Progression & Completion Loops', () => {
99+
it('should progress daily_log_today challenge and complete it on reaching target', () => {
100+
const template = CHALLENGE_TEMPLATES.find(c => c.id === 'daily_log_today')!;
101+
profile.challengeProgress['daily_log_today'] = {
102+
challengeId: 'daily_log_today',
103+
assignedAt: '2026-07-02T00:00:00.000Z',
104+
expiresAt: '2026-07-02T23:59:59.999Z',
105+
current: 0,
106+
target: 1,
107+
completed: false,
108+
completedAt: null,
109+
xpClaimed: false,
110+
};
111+
112+
const event: GamificationEvent = {
113+
type: 'transaction_created',
114+
timestamp: new Date().toISOString(),
115+
};
116+
117+
const result = processEvent(profile, event, [], today);
118+
const cp = result.updatedProfile.challengeProgress['daily_log_today'];
119+
expect(cp.current).toBe(1);
120+
expect(cp.completed).toBe(true);
121+
expect(result.xpGained).toBeGreaterThanOrEqual(template.xpReward);
122+
});
123+
124+
it('should progress weekly_streak_7 challenge on active days', () => {
125+
profile.challengeProgress['weekly_streak_7'] = {
126+
challengeId: 'weekly_streak_7',
127+
assignedAt: '2026-07-02T00:00:00.000Z',
128+
expiresAt: '2026-07-09T23:59:59.999Z',
129+
current: 0,
130+
target: 7,
131+
completed: false,
132+
completedAt: null,
133+
xpClaimed: false,
134+
};
135+
136+
profile.streak.currentDays = 7;
137+
138+
const event: GamificationEvent = {
139+
type: 'daily_streak',
140+
timestamp: new Date().toISOString(),
141+
};
142+
143+
const result = processEvent(profile, event, [], today);
144+
const cp = result.updatedProfile.challengeProgress['weekly_streak_7'];
145+
expect(cp.current).toBe(7);
146+
expect(cp.completed).toBe(true);
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)