Practice & Gamification System
Architecture
packages/shared-practices/ Shared monorepo package
├── src/
│ ├── index.ts Public API (hooks, components, utils)
│ ├── types.ts All TypeScript interfaces (incl. Grand Plan types)
│ ├── hooks/ React Query hooks (Supabase RPCs)
│ │ ├── useGrandPlan.ts Grand plan CRUD + reorder
│ │ ├── useTodayTasks.ts Unified task builder (practices + plans + prayers)
│ │ └── ... Practice hooks
│ ├── components/ Shared UI components
│ └── utils/practiceIcons.ts Icon mapping (PRACTICE_ICON_MAP)
│
DB: practice schema 10+ tables, 20+ RPCs (incl. grand_plan tables)
Apps: apps/raamattu-nyt/ Primary consumer (TanaanPage, CinemaReader, admin)
Key Concepts
Schedule Types
| Type | Behavior |
|------|----------|
| daily | Every day (default) |
| weekly | Specific weekdays [1,3,5] = Mon/Wed/Fri |
| every_n_days | Every N days |
| rotating_list | Cycle through list items |
| spontaneous | No schedule, manual only |
Content Types (Polymorphic)
| Type | Ref points to |
|------|---------------|
| prayer_set | prayer_sets.id |
| spiritual_path | spiritual_paths.id |
| prayer | prayers.id |
| prayer_calendar | prayer_calendars.id |
Session Flow
activate_practice_template(template, schedule, content)
│
▼
get_today_practice_items() → TodayPracticeItem[]
│
├─► Quick complete: start + complete in one roundtrip
│
└─► Timed session:
start_practice_session(practice_id)
│ usePracticeSession (client timer)
▼
complete_practice_session(session_id, notes, metrics)
│
▼
PracticeCompletionScreen (streak, rhythm, verse)
Streaks
- DB-computed via
get_practice_streaksRPC - Schedule-aware: weekly practices don't break on off-days
- Returns:
current_streak,longest_streak,total_completions
Guest Support
All RPCs accept p_guest_session_id. Unauthenticated users work via useGuestSession().
Hooks
| Hook | Purpose | Key RPC |
|------|---------|---------|
| useTodayPractices | Today's items + start/complete/quickComplete | get_today_practice_items |
| usePracticeTemplates(systemId) | Available templates | get_practice_templates |
| useActivatePractice | Activate with schedule + content | activate_practice_template |
| useDeactivatePractice | Remove practice | deactivate_practice |
| usePracticeSession | Client timer (no DB) | — |
| usePracticeHistory(practiceId?) | Paginated history | get_practice_history |
| usePracticeStreaks | Streaks per practice | get_practice_streaks |
| usePracticeStats(practiceId?) | Monthly/yearly stats | get_practice_stats |
| useUpdateSchedule | Change schedule | update_practice_schedule |
| useWeeklyRhythm(practiceId) | Week completed/scheduled | get_weekly_practice_rhythm |
| usePrayerSets(systemId) | Prayer set options | get_prayer_sets |
| useSpiritualPaths(systemId) | Spiritual path options | get_spiritual_paths |
| useUserPrayers | User's prayers | Direct query |
| useUserPrayerCalendars | User's calendars | Direct query |
| useGrandPlan | Grand plan CRUD + reorder | Multiple RPCs (see Grand Plan) |
| useTodayTasks | Unified task builder | — (pure computation) |
| useDiscipleshipTasks | All tasks for discipleship cinema | Composes multiple hooks |
Components
| Component | Purpose |
|-----------|---------|
| PracticeTaskRow | Practice row: icon, name, streak badge, quick-complete button, expandable verse |
| PracticeTimer | Running timer with pause/resume |
| PracticeScheduleEditor | Schedule type picker + weekday/interval config |
| PracticeCompletionScreen | Post-completion: checkmark, duration, streak, weekly rhythm bar, encouragement, verse |
Encouragement System
Post-completion encouragement messages shown on PracticeCompletionScreen.
DB Table: public.encouragement_messages
| Column | Type | Notes | |--------|------|-------| | id | uuid | PK | | system_id | text | 'raamattu-nyt' default | | text_fi | text | Finnish message | | text_en | text | English (optional) | | practice_type | text | null = generic, or specific type filter | | is_active | bool | | | sort_order | int | |
Service: apps/raamattu-nyt/src/lib/encouragementService.ts
getRandomEncouragement(systemId, practiceType?, lang)— random active message- Admin CRUD:
getAllEncouragements,createEncouragement,updateEncouragement,deleteEncouragement,toggleEncouragementActive
Hook: apps/raamattu-nyt/src/hooks/useEncouragementMessage.ts
useEncouragementMessage(practiceType?, osisRef?)→{ message, verseText, verseReference, loading }- Verse fallback chain: 1) practice osis_ref, 2) encouragement reading plan verse, 3) daily slogan
- Encouragement plan configured via
app_config.encouragement_reading_plan_id
Admin: AdminPracticesPage.tsx tabs
| Tab | Component | Purpose |
|-----|-----------|---------|
| Harjoitukset | inline | Template CRUD |
| Tilastot | inline | Aggregate stats |
| Rukoussetit | AdminPrayerSetsTab | Prayer set CRUD + items |
| Hengelliset polut | AdminSpiritualPathsTab | Spiritual path CRUD |
| Rohkaisut | AdminEncouragementTab | Encouragement message CRUD |
| Rohkaisujae | AdminEncouragementVerseTab | Reading plan verse config |
App-Side Components (not in shared-practices)
| Component | Location | Purpose |
|-----------|----------|---------|
| PracticeSessionDialog | apps/raamattu-nyt/src/components/practice/ | Full session dialog: content card, timer, notes, completion screen |
| PracticeActivationCard | same | Template activation with schedule + content picker |
| FeaturedReadingPlanPicker | apps/raamattu-nyt/src/components/today/ | Top 3 reading plans with join/active badges for "Valitse tehtävä" section |
| PracticeHistory | apps/raamattu-nyt/src/components/profile/ | Profile section history list |
| PracticeStatsCard | same | Profile section stats card |
| ProfilePractices | same | Profile practices section |
| AdminPracticesPage | apps/raamattu-nyt/src/pages/ | Full admin page with 6 tabs |
Completion Flow (PracticeSessionDialog)
User clicks practice row
│
├─► Quick complete (check button): quickComplete(practiceId)
│ └─► No dialog, just toggles done
│
└─► Timed session (row click): opens PracticeSessionDialog
│
├─ Shows content card (prayer set item / path step / custom)
├─ Shows verse text if osis_ref present
├─ PracticeTimer with pause/resume
├─ Notes textarea
│
└─► Complete button:
completeSession(sessionId, notes, metrics)
│
▼
PracticeCompletionScreen
├─ Duration + streak stats
├─ Weekly rhythm bar (X/Y)
├─ Encouragement message (random from DB)
└─ Verse card (osis_ref or encouragement plan)
Implementation Patterns
Adding a New Practice Type
- Insert into
practice.practice_templates(migration) - Add icon to
PRACTICE_ICON_MAPinutils/practiceIcons.ts - If content-linked: add content_type handler in activation flow
Dual-Write Pattern
Prayer practices write to both practice_sessions AND prayer_logs for backward compat. The complete_practice_session RPC handles this internally.
Query Key Conventions
["today-practices", stableId]
["practice-streaks", stableId]
["practice-stats", stableId, practiceId]
["practice-history", stableId, practiceId]
["practice-templates", systemId]
["weekly-practice-rhythm", practiceId, stableId]
Moving Features to shared-practices
- Extract hook/component from
apps/raamattu-nyt/ - Place in
packages/shared-practices/src/ - Export from
index.ts - Update app imports to
@shared-practices/... - Avoid app-specific imports (use generic Supabase client pattern)
Grand Plan System
Orchestration layer that groups diverse content types into named task collections with sort order.
Concepts
- Personal plan: auto-created per user, holds their active practices/plans/prayers
- Curated plan: admin-created, shared across users (e.g. "30 Days of Prayer")
- Auto-linking: triggers automatically add/remove items when practices, reading plans, or prayers are activated/deactivated
- Sort order:
grand_plan_items.sort_orderdefines task display order on Tänään page
DB Tables (practice schema)
| Table | Purpose |
|-------|---------|
| grand_plans | Plan metadata: name, type (personal/curated), progression_type, created_by |
| user_grand_plans | Membership: user_id, is_primary, current_day, status |
| grand_plan_items | Tasks: item_type, item_ref (UUID), template_ref, sort_order, day_number |
Item types: practice, reading_plan, prayer, prayer_calendar, kooste
RPCs
| RPC | Purpose |
|-----|---------|
| get_or_create_personal_plan(user_id) | Returns/creates personal plan UUID |
| add_item_to_grand_plan(...) | Add item with auto sort_order |
| remove_item_from_grand_plan(plan_id, item_id) | Remove item |
| reorder_grand_plan_items(plan_id, item_ids[]) | Batch reorder by array position |
| get_user_grand_plans() | List user's plans with item_count |
| get_grand_plan_items(plan_id) | Items with resolved_name/icon |
| join_grand_plan(plan_id) | Join curated plan |
Auto-Link Triggers
| Trigger | Source table | Action |
|---------|-------------|--------|
| trg_practice_auto_link_grand_plan | practice.practices INSERT | Add item_type='practice' |
| trg_practice_auto_unlink_grand_plan | practice.practices UPDATE (deactivation) | Delete item |
| trg_reading_plan_auto_link_grand_plan | bible_schema.user_reading_plans INSERT | Add item_type='reading_plan' |
| trg_reading_plan_auto_unlink_grand_plan | bible_schema.user_reading_plans UPDATE | Delete item |
| trg_prayer_auto_link_grand_plan | public.prayers INSERT (status='active') | Add item_type='prayer' |
| trg_prayer_auto_unlink_grand_plan | public.prayers UPDATE (deactivated) | Delete item |
useGrandPlan Hook
const { plan, items, reorderItems, addItem, removeItem, loading } = useGrandPlan();
// reorderItems(itemIds[]) — optimistic UI + invalidates cache
Admin
AdminGrandPlansTab in AdminPracticesPage — CRUD for plans and items, accessed via admin RPCs.
Task Reordering (Tänään Page)
Users reorder tasks via useTodayDashboard().reorderItems.
Flow
TanaanPage
├── useTodayDashboard() → { items, reorderItems }
│ items already sorted by sort_order from the RPC
└── Pass onReorder to TodayTaskList
TodayTaskList
├── "Muokkaa järjestystä" toggle → shows ChevronUp/Down per row
└── handleSwap → collect item_ids in new order → onReorder(itemIds)
└── reorderItems(itemIds) → reorder_grand_plan_items RPC + cache invalidation
Discipleship Mode
Full-screen Cinema Reader orchestration for structured daily routines.
Entry Point
TanaanPage → two cinema buttons → CinemaReaderScreen with discipleshipTasks
Two Launch Modes
| Mode | Button | Props | Behavior |
|------|--------|-------|----------|
| Reading Plan Cinema | Blue Play | discipleshipTasks={readingPlanCinemaTasks} autoStart | Auto-plays reading plans only. No task selector, no quizzes, no transitions. |
| Discipleship Cinema | Amber Clapperboard | discipleshipTasks={allTasks} | Full flow: task selector → reading + prayer + practice → quizzes → kooste. |
Hook: useDiscipleshipTasks
Location: apps/raamattu-nyt/src/hooks/useDiscipleshipTasks.ts
Composes multiple data sources into UnifiedTodayTask[]:
- Active practices (filtered: no prayer_set, no empty prayer_calendar)
- Active reading plans (with duration from
useReadingPlanDurations, passesprogression_type) - Today's scheduled prayers
- Appends "Pohdittavat jakeet" (kooste) task at the end (
NYT_KOOSTE_TASK_ID)
useTodayTasks (Unified Task Builder)
Location: packages/shared-practices/src/hooks/useTodayTasks.ts
Normalizes heterogeneous sources into UnifiedTodayTask[]:
- ID format:
rp-{id},pr-{id},py-{id} - Duration: reading plans use
estimateReadingMinutes(verseCount), practices use template default, prayers use fallback ReadingPlanInputaccepts optionalprogression_typefor correct completion detection- isCompletedToday for reading plans: Checks
completed_days.includes(current_day)OR for completion-type plans (not calendar/day_of_year) also checkscompleted_days.includes(current_day - 1)becausemark_reading_day_completeadvancescurrent_dayimmediately - Returns:
{ tasks, completed, uncompleted, totalDurationMinutes }
CinemaReaderScreen Discipleship Flow
Open with discipleshipTasks + optional autoStart/initialTask
│
├─► autoStart=true → queue all uncompleted, start first
│ No selector, no toggle, no quizzes, no transitions
│
├─► initialTask provided → start immediately
│
└─► No initialTask → show DiscipleshipTaskSelector
│
▼
Task execution:
├─ reading_plan → fetch verses, display in cinema
│ → mark_reading_day_complete RPC on finish
│ → auto-insert memory quiz after (discipleship only, NOT autoStart)
│ → quiz prioritizes user-marked verses (marked_refs from VerseBar)
├─ practice/prayer → DiscipleshipInlineTask (inline view)
└─ kooste → load accumulated verse refs, display in cinema
│
▼
Task complete → DiscipleshipTransitionOverlay (auto-skipped in autoStart)
├─ Show completion summary
├─ "Next task" / free task selection
└─ Skip completed tasks in queue
Reading Plan Completion Gotcha
mark_reading_day_complete advances current_day immediately (5→6). This affects:
- Dashboard (
get_today_dashboard): For completion-type plans, checkscompleted_at::date = CURRENT_DATE(notcurrent_day) - Client (
useTodayTasks): Also checkscompleted_days.includes(current_day - 1)for completion-type plans - Query invalidation: Must invalidate
["today-dashboard"],["reading-plan-streaks"], AND["user-reading-plans"]
Quiz Marked Verse Priority
When user marks verses via DiscipleshipVerseBar (add to Nyt Kooste), those refs are passed as marked_refs in the quiz task metadata. useReadingPlanQuiz prioritizes them:
- 2+ marked in plan → both quiz verses from marked
- 1 marked → first from marked, second from remaining
- 0 marked → prefer NT/Psalms/Proverbs
Reading Duration Estimates
Location: apps/raamattu-nyt/src/lib/readingDuration.ts
countVersesFromReadings(readings)— total verses across references (handles multi-chapter ranges)estimateReadingMinutes(verseCount)—Math.max(1, Math.round(verseCount * 0.13))(~8 sec/verse)- Used by
useReadingPlanDurationshook to compute per-plan estimated minutes
Tänään Page Integration
The Tänään (Today) page (TanaanPage.tsx) is the primary consumer of practice data.
Architecture
TanaanPage
├── useTodayDashboard() → unified dashboard (replaces ~13 hooks)
│ └── get_today_dashboard RPC → DashboardItem[] (practices, plans, prayers, kooste)
├── useDiscipleshipTasks() → tasks for Cinema discipleship mode
├── Daily tasks section (#today)
│ └── TodayTaskList
│ ├── Empty state: "Ei tehtäviä" → scrolls to #subscribe
│ ├── Uncompleted tasks ("Mahdollisuudet")
│ ├── "Pohdittavat jakeet" (kooste) row
│ └── Completed tasks ("Tehty tänään")
│ ├── Blue Play button → Reading Plan Cinema (autoStart)
│ └── Amber Clapperboard → Discipleship Cinema
├── Tomorrow preview (#tomorrow)
│ └── TomorrowTasksSection
├── Permanent prayers (#permanent)
│ └── PermanentTasksSection (priority_level='always')
├── Recurring tasks (#recurring)
│ └── RecurringTasksSection
└── "Valitse tehtävä" section (#subscribe)
├── Always visible (not gated on templates.length)
├── Auto-opens when user has zero dashboard items
├── FeaturedReadingPlanPicker (top 3 global plans, "Aloita"/"Aktiivinen")
└── Harjoitukset (PracticeActivationCard per template)
Unified Dashboard Hook: useTodayDashboard
Location: apps/raamattu-nyt/src/hooks/useTodayDashboard.ts
Replaces the old pattern of calling useGrandPlan + useTodayPractices + useReadingPlans separately.
Single RPC get_today_dashboard(p_grand_plan_id) returns DashboardItem[] with:
item_type: practice | reading_plan | prayer | kooste | mini_taskis_completed_today,current_streak,sort_ordermetadataobject varies by item_type (seeDashboardPracticeMetadata, etc.)
Provides converter functions: toPracticeItem(), toReadingPlan(), toPrayer().
"Valitse tehtävä" Section
Renamed from "Valitse harjoitus". Contains two sub-sections:
-
Lukusuunnitelmat —
FeaturedReadingPlanPickercomponent- Shows top 3 available plans from
useReadingPlans().availablePlans - "Aloita" button calls
joinPlanWithCheck, shows "Aktiivinen" badge if joined - Waits for
userPlansLoadingbefore rendering to avoid false "Aloita" state - "Näytä kaikki" link →
/reading-plans onPlanJoinedcallback invalidates dashboard
- Shows top 3 available plans from
-
Harjoitukset — existing
PracticeActivationCardlist (unchanged)
prayer_calendar Content Type Gotcha
Prayer calendar items always resolve the latest active prayer from the calendar, not a today-scheduled prayer. The RPC fetches prayers where calendar_id = content_ref and status = 'active', ordered by most recent.
get_practice_items_for_date RPC
Parameterized version of get_today_practice_items that accepts a target date:
const { data } = useTodayPractices(); // today (no date param)
const { data } = useTomorrowPractices(); // calls get_practice_items_for_date with tomorrow
Future: Rewards & Badges (Not Yet Implemented)
Design considerations:
- Streak milestones (7, 30, 100 days) trigger rewards
- Badges for completing spiritual paths
- XP from session metrics (duration, verse count)
- Tables:
practice.user_rewards,practice.user_badges - PracticeCompletionScreen already shows streak — extend with badge unlock
DB Reference
See references/db-schema.md for full table and RPC reference.