This commit is contained in:
kiran 2026-02-07 14:39:07 +05:30
commit ebc96b1f99
66 changed files with 24115 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Node.js
node_modules/
npm-debug.log
yarn-error.log
pnpm-debug.log*
dist/
build/
coverage/
.npm/
# Editors
.vscode/
.idea/
*.swp
*.swo
*.swn
.project
.settings/
.classpath
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS generated files
Thumbs.db
ehthumbs.db
Desktop.ini

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# Surge
Surge is a monorepo project containing a mobile application and a backend service.
## Project Structure
- **mobile**: A React Native / Expo application.
- **backend**: A Node.js / TypeScript backend service.
## Getting Started
Please refer to the individual directories for specific setup and running instructions.

1398
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "ts-node src/index.ts",
"dev": "nodemon src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@fastify/cors": "^11.2.0",
"dotenv": "^17.2.4",
"fastify": "^5.7.4",
"pg": "^8.18.0"
},
"devDependencies": {
"@types/node": "^25.2.1",
"@types/pg": "^8.16.0",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

89
backend/src/db/schema.sql Normal file
View File

@ -0,0 +1,89 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
firebase_uid VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Challenges table
CREATE TABLE IF NOT EXISTS challenges (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
duration_days INTEGER NOT NULL,
difficulty VARCHAR(50) NOT NULL, -- 'beginner', 'moderate', 'hard', 'extreme'
is_active BOOLEAN DEFAULT TRUE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_challenges_slug ON challenges(slug);
CREATE INDEX IF NOT EXISTS idx_challenges_is_active ON challenges(is_active);
-- Challenge Requirements table
CREATE TABLE IF NOT EXISTS challenge_requirements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
challenge_id UUID REFERENCES challenges(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
type VARCHAR(50) NOT NULL, -- 'BOOLEAN', 'NUMERIC', 'DURATION', 'PHOTO_PROOF'
validation_rules JSONB DEFAULT '{}',
sort_order INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_challenge_requirements_challenge_id ON challenge_requirements(challenge_id);
-- User Challenges table
CREATE TABLE IF NOT EXISTS user_challenges (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
challenge_id UUID REFERENCES challenges(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
status VARCHAR(50) NOT NULL, -- 'ACTIVE', 'COMPLETED', 'FAILED', 'PAUSED'
current_streak INTEGER DEFAULT 0,
longest_streak INTEGER DEFAULT 0,
attempt_number INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_challenges_user_status ON user_challenges(user_id, status);
CREATE INDEX IF NOT EXISTS idx_user_challenges_user_challenge ON user_challenges(user_id, challenge_id);
-- Daily Progress table
CREATE TABLE IF NOT EXISTS daily_progress (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_challenge_id UUID REFERENCES user_challenges(id) ON DELETE CASCADE,
progress_date DATE NOT NULL,
day_number INTEGER NOT NULL,
is_complete BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_challenge_id, progress_date)
);
CREATE INDEX IF NOT EXISTS idx_daily_progress_user_challenge_date ON daily_progress(user_challenge_id, progress_date);
-- Task Completions table
CREATE TABLE IF NOT EXISTS task_completions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
daily_progress_id UUID REFERENCES daily_progress(id) ON DELETE CASCADE,
requirement_id UUID REFERENCES challenge_requirements(id) ON DELETE CASCADE,
completion_data JSONB DEFAULT '{}',
completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_task_completions_daily_progress ON task_completions(daily_progress_id);

136
backend/src/db/seed.ts Normal file
View File

@ -0,0 +1,136 @@
import { Pool } from 'pg';
import * as fs from 'fs';
import * as path from 'path';
const pool = new Pool({
user: process.env.POSTGRES_USER || 'surge',
host: process.env.POSTGRES_HOST || 'localhost',
database: process.env.POSTGRES_DB || 'surge_db',
password: process.env.POSTGRES_PASSWORD || 'password',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
});
const challenges = [
{
name: '75 Hard',
slug: '75-hard',
description: 'The ultimate mental toughness challenge. No cheat meals, no alcohol, two workouts a day.',
duration_days: 75,
difficulty: 'extreme',
requirements: [
{ title: 'Two 45-minute workouts (one outdoors)', type: 'BOOLEAN', sort_order: 1 },
{ title: 'Follow a diet', type: 'BOOLEAN', sort_order: 2 },
{ title: 'No alcohol or cheat meals', type: 'BOOLEAN', sort_order: 3 },
{ title: 'Drink 1 gallon of water', type: 'BOOLEAN', sort_order: 4 },
{ title: 'Read 10 pages of non-fiction', type: 'BOOLEAN', sort_order: 5 },
{ title: 'Take a progress photo', type: 'PHOTO_PROOF', sort_order: 6 },
],
},
{
name: '75 Soft',
slug: '75-soft',
description: 'A more sustainable version of 75 Hard for building consistent habits.',
duration_days: 75,
difficulty: 'moderate',
requirements: [
{ title: 'Eat well and only drink on social occasions', type: 'BOOLEAN', sort_order: 1 },
{ title: 'Train for 45 minutes everyday (one day of active recovery)', type: 'BOOLEAN', sort_order: 2 },
{ title: 'Drink 3 liters of water', type: 'BOOLEAN', sort_order: 3 },
{ title: 'Read 10 pages of any book', type: 'BOOLEAN', sort_order: 4 },
],
},
{
name: '30-Day Fitness Challenge',
slug: '30-day-fitness',
description: 'A balanced fitness program to jumpstart your physical health.',
duration_days: 30,
difficulty: 'moderate',
requirements: [
{ title: '30 minutes of exercise', type: 'BOOLEAN', sort_order: 1 },
{ title: 'No sugar', type: 'BOOLEAN', sort_order: 2 },
{ title: 'Sleep 7+ hours', type: 'BOOLEAN', sort_order: 3 },
],
},
{
name: '21-Day Meditation Challenge',
slug: '21-day-meditation',
description: 'Build a daily mindfulness practice in just three weeks.',
duration_days: 21,
difficulty: 'beginner',
requirements: [
{ title: 'Meditate for 10 minutes', type: 'BOOLEAN', sort_order: 1 },
{ title: 'Write in gratitude journal', type: 'BOOLEAN', sort_order: 2 },
],
},
{
name: '30-Day No Sugar Challenge',
slug: '30-day-no-sugar',
description: 'Reset your palate and break the sugar addiction.',
duration_days: 30,
difficulty: 'moderate',
requirements: [
{ title: 'No added sugar', type: 'BOOLEAN', sort_order: 1 },
{ title: 'Check labels on all food', type: 'BOOLEAN', sort_order: 2 },
{ title: 'Eat 2 pieces of fruit max', type: 'BOOLEAN', sort_order: 3 },
],
},
];
async function seed() {
const client = await pool.connect();
try {
console.log('Starting database seed...');
// Read and execute schema
const schemaPath = path.join(__dirname, 'schema.sql');
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
await client.query(schemaSql);
console.log('Schema applied successfully.');
// Clear existing challenges to avoid duplicates (optional, for development)
// await client.query('TRUNCATE challenges CASCADE');
for (const challenge of challenges) {
// Check if challenge exists
const res = await client.query('SELECT id FROM challenges WHERE slug = $1', [challenge.slug]);
let challengeId;
if (res.rows.length > 0) {
console.log(`Challenge ${challenge.name} already exists, updating...`);
challengeId = res.rows[0].id;
// Update logic if needed, or skip
} else {
console.log(`Inserting challenge: ${challenge.name}`);
const insertRes = await client.query(
`INSERT INTO challenges (name, slug, description, duration_days, difficulty)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[challenge.name, challenge.slug, challenge.description, challenge.duration_days, challenge.difficulty]
);
challengeId = insertRes.rows[0].id;
}
// Insert requirements
// First delete existing requirements for this challenge to ensure clean state
await client.query('DELETE FROM challenge_requirements WHERE challenge_id = $1', [challengeId]);
for (const req of challenge.requirements) {
await client.query(
`INSERT INTO challenge_requirements (challenge_id, title, type, sort_order)
VALUES ($1, $2, $3, $4)`,
[challengeId, req.title, req.type, req.sort_order]
);
}
}
console.log('Database seeding completed successfully.');
} catch (err) {
console.error('Error seeding database:', err);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
seed();

92
backend/src/index.ts Normal file
View File

@ -0,0 +1,92 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { Pool } from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const fastify = Fastify({
logger: true
});
// Register CORS
fastify.register(cors, {
origin: true, // Allow all origins for development
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
});
const pool = new Pool({
user: process.env.POSTGRES_USER || 'surge',
host: process.env.POSTGRES_HOST || 'localhost',
database: process.env.POSTGRES_DB || 'surge_db',
password: process.env.POSTGRES_PASSWORD || 'password',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
});
// Health check
fastify.get('/health', async (request, reply) => {
try {
await pool.query('SELECT 1');
return { status: 'ok', db: 'connected' };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ status: 'error', db: 'disconnected' });
}
});
// GET /challenges
fastify.get('/challenges', async (request, reply) => {
try {
const result = await pool.query(
'SELECT id, name, slug, description, duration_days, difficulty, is_active FROM challenges WHERE is_active = true ORDER BY name ASC'
);
return result.rows;
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: 'Internal Server Error' });
}
});
// GET /challenges/:id
fastify.get<{ Params: { id: string } }>('/challenges/:id', async (request, reply) => {
const { id } = request.params;
try {
// Fetch challenge details
const challengeRes = await pool.query(
'SELECT * FROM challenges WHERE id = $1',
[id]
);
if (challengeRes.rows.length === 0) {
return reply.code(404).send({ error: 'Challenge not found' });
}
const challenge = challengeRes.rows[0];
// Fetch requirements
const requirementsRes = await pool.query(
'SELECT * FROM challenge_requirements WHERE challenge_id = $1 ORDER BY sort_order ASC',
[id]
);
return {
...challenge,
requirements: requirementsRes.rows
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: 'Internal Server Error' });
}
});
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('Server listening on http://localhost:3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

14
backend/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

24
docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: surge
POSTGRES_PASSWORD: password
POSTGRES_DB: surge_db
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:

457
docs/architecture.md Normal file
View File

@ -0,0 +1,457 @@
# Architecture High-Level Design: Surge
## Executive Summary
This Architecture High-Level Design establishes the technical foundation for Surge, a mobile application enabling users to discover and complete structured self-improvement challenges. Building upon the Feature Definition's prioritization of the daily check-in experience and streak psychology, this architecture emphasizes responsive local-first interactions, reliable data synchronization, and a foundation that supports future social features without over-engineering the MVP.
The design balances immediate delivery needs with strategic positioning for Phase 2 social capabilities, ensuring the core tracking experience remains fast and satisfying even under poor network conditions.
***
## System Architecture Overview
```mermaid
graph TB
subgraph "Client Layer"
MA[Mobile App<br/>React Native]
LS[(Local Storage<br/>SQLite/Realm)]
end
subgraph "API Layer"
AG[API Gateway<br/>AWS API Gateway]
AUTH[Auth Service<br/>Firebase Auth]
end
subgraph "Application Layer"
US[User Service]
CS[Challenge Service]
PS[Progress Service]
end
subgraph "Data Layer"
PG[(PostgreSQL<br/>Primary DB)]
RC[(Redis<br/>Cache/Sessions)]
end
subgraph "Supporting Services"
PN[Push Notifications<br/>Firebase FCM]
AN[Analytics<br/>Mixpanel/Amplitude]
end
MA <--> LS
MA <--> AG
AG <--> AUTH
AG <--> US
AG <--> CS
AG <--> PS
US <--> PG
CS <--> PG
PS <--> PG
PS <--> RC
US <--> PN
MA --> AN
```
***
## Technology Stack
### Mobile Application
| Layer | Technology | Rationale |
| ----- | ---------- | --------- |
| Framework | React Native | Cross-platform efficiency, strong ecosystem, team familiarity |
| State Management | Zustand | Lightweight, minimal boilerplate, excellent for offline-first patterns |
| Local Database | WatermelonDB | Optimized for React Native, built-in sync capabilities, lazy loading |
| Navigation | React Navigation | Industry standard, deep linking support |
| UI Components | Custom + React Native Reanimated | Bold, high-energy design requires custom animations |
### Backend Services
| Component | Technology | Rationale |
| --------- | ---------- | --------- |
| Runtime | Node.js with TypeScript | Type safety, shared models with frontend, async performance |
| Framework | Fastify | High performance, schema validation, lower overhead than Express |
| Database | PostgreSQL 15 | ACID compliance, JSON support, proven reliability for user data |
| Cache | Redis | Session management, streak calculations, leaderboard preparation |
| Authentication | Firebase Auth | Rapid implementation, social login support, secure token management |
### Infrastructure
| Component | Technology | Rationale |
| --------- | ---------- | --------- |
| Cloud Provider | AWS | Comprehensive services, reliable, cost-effective at scale |
| Container Orchestration | AWS ECS Fargate | Serverless containers, reduced operational overhead |
| API Management | AWS API Gateway | Rate limiting, request validation, easy Lambda integration if needed |
| CDN | CloudFront | Challenge asset delivery, global edge caching |
| CI/CD | GitHub Actions | Integrated with codebase, cost-effective, extensive marketplace |
***
## Core Component Design
### Challenge Service
Manages the challenge library and challenge definitions. As noted in Feature Definition, launching with 5 well-documented challenges is prioritized over quantity.
```mermaid
classDiagram
class Challenge {
+uuid id
+string name
+string description
+int duration_days
+DailyRequirement[] requirements
+DifficultyLevel difficulty
+string[] tags
+boolean is_active
}
class DailyRequirement {
+uuid id
+string title
+string description
+RequirementType type
+json validation_rules
+int sort_order
}
class RequirementType {
<<enumeration>>
BOOLEAN
NUMERIC
DURATION
PHOTO_PROOF
}
Challenge "1" --> "*" DailyRequirement
DailyRequirement --> RequirementType
```
**Design Decisions:**
* Challenge definitions are admin-managed, cached aggressively on device
* Requirement types support future extensibility (photo proof for social features)
* Validation rules stored as JSON for flexible challenge-specific logic
### Progress Service
The heart of the user experience. Following Feature Definition's emphasis on making check-ins "fast, satisfying, and visually rewarding," this service prioritizes write performance and immediate feedback.
```mermaid
classDiagram
class UserChallenge {
+uuid id
+uuid user_id
+uuid challenge_id
+date start_date
+ChallengeStatus status
+int current_streak
+int longest_streak
+int attempt_number
}
class DailyProgress {
+uuid id
+uuid user_challenge_id
+date progress_date
+int day_number
+boolean is_complete
+timestamp completed_at
}
class TaskCompletion {
+uuid id
+uuid daily_progress_id
+uuid requirement_id
+json completion_data
+timestamp completed_at
}
class ChallengeStatus {
<<enumeration>>
ACTIVE
COMPLETED
FAILED
PAUSED
}
UserChallenge "1" --> "*" DailyProgress
DailyProgress "1" --> "*" TaskCompletion
UserChallenge --> ChallengeStatus
```
**Streak Calculation Strategy:**
* Current streak calculated on write (not read) for instant UI updates
* Redis maintains hot streak data for active users
* Nightly batch job reconciles any sync discrepancies
* `attempt_number` tracks restarts, supporting Feature Definition's "encouraging restart experience"
### User Service
Handles authentication, profile management, and notification preferences.
```mermaid
sequenceDiagram
participant App
participant Firebase
participant API
participant DB
App->>Firebase: Social Login (Google/Apple)
Firebase-->>App: ID Token
App->>API: POST /auth/verify
API->>Firebase: Verify Token
Firebase-->>API: User Claims
API->>DB: Upsert User
DB-->>API: User Record
API-->>App: JWT + User Profile
App->>App: Store JWT Securely
```
***
## Offline-First Architecture
Given that daily check-ins are the core interaction, the app must function reliably regardless of network conditions.
```mermaid
graph LR
subgraph "User Action"
A[Complete Task]
end
subgraph "Local First"
B[Write to Local DB]
C[Update UI Immediately]
D[Queue Sync Operation]
end
subgraph "Background Sync"
E{Network Available?}
F[Sync to Server]
G[Retry with Backoff]
H[Conflict Resolution]
end
A --> B
B --> C
B --> D
D --> E
E -->|Yes| F
E -->|No| G
F --> H
G -.->|Retry| E
```
**Sync Strategy:**
* All progress writes happen locally first, providing instant feedback
* Background sync with exponential backoff (5s, 15s, 45s, 2min max)
* Last-write-wins conflict resolution (acceptable for single-user MVP)
* Server timestamp used as source of truth for streak calculations
* Sync queue persisted to survive app termination
***
## Data Architecture
### PostgreSQL Schema (Simplified)
```sql
-- Core tables with indexes optimized for common queries
users (id, firebase_uid, email, display_name, created_at, updated_at)
INDEX: firebase_uid (unique), email
challenges (id, name, slug, duration_days, difficulty, is_active, metadata)
INDEX: slug (unique), is_active
challenge_requirements (id, challenge_id, title, type, validation_rules, sort_order)
INDEX: challenge_id
user_challenges (id, user_id, challenge_id, start_date, status, current_streak, attempt_number)
INDEX: (user_id, status), (user_id, challenge_id)
daily_progress (id, user_challenge_id, progress_date, day_number, is_complete, completed_at)
INDEX: (user_challenge_id, progress_date) UNIQUE
task_completions (id, daily_progress_id, requirement_id, completion_data, completed_at)
INDEX: daily_progress_id
```
### Redis Data Structures
```
# Active user streaks (hot data)
streak:{user_id}:{challenge_id} -> { current: 45, longest: 45, last_date: "2024-01-15" }
TTL: 7 days (refreshed on activity)
# Session management
session:{token} -> { user_id, expires_at, device_id }
TTL: 30 days
# Future: Leaderboard preparation
leaderboard:{challenge_id}:daily -> Sorted Set (user_id -> streak)
```
***
## API Design
RESTful API with consistent patterns. Key endpoints:
| Endpoint | Method | Purpose |
| -------- | ------ | ------- |
| `/challenges` | GET | List active challenges (cached) |
| `/challenges/{id}` | GET | Challenge details with requirements |
| `/me/challenges` | GET | User's active and past challenges |
| `/me/challenges` | POST | Start a new challenge |
| `/me/challenges/{id}/progress` | GET | Full progress for a challenge |
| `/me/challenges/{id}/today` | GET | Today's tasks and completion status |
| `/me/challenges/{id}/today` | PATCH | Update task completions |
| `/sync` | POST | Batch sync for offline changes |
**Response Time Targets:**
* Challenge library: <100ms (CDN cached)
* Today's progress: <150ms (Redis + DB)
* Task completion: <200ms (write path)
***
## Security Architecture
```mermaid
graph TB
subgraph "Client Security"
A[Secure Token Storage<br/>iOS Keychain / Android Keystore]
B[Certificate Pinning]
C[Biometric Lock Option]
end
subgraph "Transport Security"
D[TLS 1.3]
E[API Gateway Rate Limiting]
end
subgraph "Backend Security"
F[JWT Validation]
G[Row-Level Security]
H[Input Validation<br/>Fastify Schemas]
end
A --> D
B --> D
D --> E
E --> F
F --> G
F --> H
```
**Key Security Measures:**
* Firebase Auth handles credential security
* Short-lived JWTs (1 hour) with refresh token rotation
* All user data queries filtered by authenticated user\_id
* Rate limiting: 100 requests/minute per user
* Input validation at API gateway and service layers
***
## Scalability Considerations
**MVP Scale (10K users):**
* Single PostgreSQL instance (db.t3.medium)
* Single Redis instance (cache.t3.micro)
* 2 ECS tasks behind ALB
* Estimated cost: \~$150/month
**Growth Path (100K+ users):**
* PostgreSQL read replicas for challenge library queries
* Redis cluster for streak calculations
* Horizontal scaling of stateless API services
* Consider Aurora Serverless for variable load
**Social Features Preparation:**
* User ID foreign keys in place for future friend relationships
* Redis sorted sets ready for leaderboard implementation
* Event-driven architecture allows adding notification triggers
***
## Deployment Architecture
```mermaid
graph TB
subgraph "Production"
ALB[Application Load Balancer]
ECS1[ECS Task 1]
ECS2[ECS Task 2]
RDS[(RDS PostgreSQL)]
REDIS[(ElastiCache Redis)]
end
subgraph "CI/CD"
GH[GitHub Actions]
ECR[ECR Registry]
end
subgraph "Monitoring"
CW[CloudWatch]
SENTRY[Sentry]
end
GH --> ECR
ECR --> ECS1
ECR --> ECS2
ALB --> ECS1
ALB --> ECS2
ECS1 --> RDS
ECS2 --> RDS
ECS1 --> REDIS
ECS2 --> REDIS
ECS1 --> CW
ECS1 --> SENTRY
```
**Deployment Strategy:**
* Blue/green deployments via ECS
* Database migrations run as pre-deployment task
* Feature flags for gradual rollouts
* Automated rollback on health check failures
***
## Recommendations
1. **Invest in Local-First Infrastructure**: The offline-first pattern is critical for the daily check-in experience. Allocate adequate time for sync logic and conflict handling.
2. **Implement Comprehensive Analytics Early**: As noted in Feature Definition, event tracking from day one informs Phase 2 social features. Instrument all user interactions.
3. **Design APIs for Mobile Efficiency**: Combine related data in single responses (today's tasks + streak + progress) to minimize round trips.
4. **Plan for Streak Edge Cases**: Timezone handling, daylight saving transitions, and missed-day scenarios need careful consideration in both client and server logic.
5. **Prepare Social Foundation Without Building It**: Include user\_id relationships and Redis structures that support leaderboards, but don't implement social features until validated.
***
## Technical Risks & Mitigations
| Risk | Impact | Mitigation |
| ---- | ------ | ---------- |
| Offline sync conflicts | Data loss, user frustration | Comprehensive conflict resolution, sync status UI |
| Streak calculation errors | Core feature broken | Server-side validation, reconciliation jobs, audit logs |
| Firebase Auth dependency | Authentication outage | Graceful degradation, cached sessions |
| React Native performance | Poor animation experience | Native driver animations, performance profiling |
***
## Next Steps
1. Set up infrastructure-as-code (Terraform/CDK) for reproducible environments
2. Implement authentication flow and user service
3. Build challenge service with seed data for 5 launch challenges
4. Develop progress service with offline-first client integration
5. Establish CI/CD pipeline with staging environment

317
docs/features.md Normal file
View File

@ -0,0 +1,317 @@
# Feature Definition: Surge
## Executive Summary
Surge is a mobile application designed to help users discover, track, and complete structured self-improvement challenges. This feature definition establishes the core functionality required for MVP launch, focusing on the challenge library and personal progress tracking systems that form the foundation of the user experience.
The MVP prioritizes delivering immediate value to fitness enthusiasts and habit builders by solving their primary pain point: staying organized and accountable during demanding multi-day challenges. Social features are intentionally deferred to post-MVP phases.
***
## User Personas & Scenarios
### Primary Personas
**The Committed Challenger (Primary)**
* Demographics: 25-40 years old, fitness-oriented
* Goals: Complete demanding challenges like 75 Hard without losing track
* Pain Points: Currently uses spreadsheets or notes apps, loses momentum mid-challenge
* Behavior: Checks progress daily, motivated by streaks and visual progress
**The Habit Explorer (Secondary)**
* Demographics: 20-35 years old, self-improvement focused
* Goals: Discover and try various challenge formats to build better habits
* Pain Points: Doesn't know which challenges exist or which suit their goals
* Behavior: Browses options before committing, prefers shorter challenges initially
### Key User Scenarios
**Scenario 1: Starting a New Challenge**
Sarah discovers Surge after deciding to attempt 75 Hard. She browses the challenge library, reads the specific requirements (two workouts, diet adherence, water intake, reading, progress photo), and starts the challenge with a clear understanding of daily expectations.
**Scenario 2: Daily Check-in Routine**
Marcus is on Day 23 of his challenge. Each evening, he opens Surge, checks off completed tasks for the day, sees his streak count increase, and views his progress visualization showing he's 30% complete.
**Scenario 3: Recovery from Missed Day**
Elena missed a requirement on Day 15 of 75 Hard (the challenge requires restarting on any failure). Surge clearly shows her the rule was broken, offers encouragement, and provides a simple path to restart with her history preserved.
***
## Feature Specifications
### Feature 1: Challenge Library
**Purpose:** Provide users access to a curated collection of structured challenges with clear rules and requirements.
**User Value:** Users no longer need to research challenge rules across multiple sources—everything is centralized and clearly explained.
#### Functional Requirements
| ID | Requirement | Priority |
| --- | ----------- | -------- |
| CL-01 | Display browsable list of available challenges | Must Have |
| CL-02 | Show challenge details: name, duration, description, difficulty | Must Have |
| CL-03 | List specific daily requirements for each challenge | Must Have |
| CL-04 | Filter challenges by duration and difficulty | Should Have |
| CL-05 | Search challenges by name or keyword | Should Have |
| CL-06 | Display challenge rules and failure conditions | Must Have |
| CL-07 | Show estimated daily time commitment | Nice to Have |
#### MVP Challenge Content
The library will launch with these challenges:
1. **75 Hard** \- 75 days\, extreme difficulty
2. **75 Soft** \- 75 days\, moderate difficulty
3. **30-Day Fitness Challenge** \- 30 days\, moderate
4. **21-Day Meditation Challenge** \- 21 days\, beginner
5. **30-Day No Sugar Challenge** \- 30 days\, moderate
```mermaid
graph TD
A[Challenge Library] --> B[Browse All]
A --> C[Filter/Search]
B --> D[Challenge Card]
C --> D
D --> E[Challenge Details]
E --> F[View Requirements]
E --> G[Start Challenge]
G --> H[Active Challenge]
```
#### Acceptance Criteria
* User can view all available challenges within 2 seconds of opening library
* Each challenge displays duration, difficulty rating, and brief description on card
* Challenge detail view shows complete daily requirements list
* User can start any challenge with single tap from detail view
***
### Feature 2: Personal Progress Tracking
**Purpose:** Enable users to track daily completion of challenge requirements and visualize their journey.
**User Value:** Replaces scattered tracking methods with a dedicated system that understands each challenge's specific structure.
#### Functional Requirements
| ID | Requirement | Priority |
| --- | ----------- | -------- |
| PT-01 | Display current day's required tasks as checkable items | Must Have |
| PT-02 | Save daily completion status persistently | Must Have |
| PT-03 | Show current streak count prominently | Must Have |
| PT-04 | Display overall progress percentage/visualization | Must Have |
| PT-05 | Track challenge start date and current day number | Must Have |
| PT-06 | Handle challenge-specific failure rules | Must Have |
| PT-07 | Allow viewing of past days' completion history | Should Have |
| PT-08 | Send daily reminder notifications | Should Have |
| PT-09 | Support multiple concurrent challenges | Nice to Have |
#### Daily Check-in Flow
```mermaid
sequenceDiagram
participant U as User
participant A as App
participant S as Storage
U->>A: Open Daily View
A->>S: Fetch today's requirements
S-->>A: Return task list + status
A-->>U: Display checkable tasks
U->>A: Mark task complete
A->>S: Update completion status
A->>A: Recalculate streak/progress
A-->>U: Update UI with new stats
U->>A: Complete all tasks
A-->>U: Show celebration + streak update
```
#### Progress Visualization
The progress dashboard displays:
* **Current Day**: "Day 23 of 75"
* **Streak Counter**: Consecutive days completed
* **Progress Bar**: Visual percentage complete
* **Calendar View**: Month view with completion indicators
* **Today's Status**: Remaining tasks for current day
#### Acceptance Criteria
* Daily tasks reflect the specific challenge's requirements
* Checking off tasks updates immediately with visual feedback
* Streak count updates upon completing all daily requirements
* Progress percentage calculates accurately based on days completed
* Past completion data persists across app sessions
* Failed challenges (per challenge rules) prompt restart option
***
### Feature 3: Challenge Lifecycle Management
**Purpose:** Handle the complete journey from starting a challenge through completion or restart.
**User Value:** Clear guidance through challenge states reduces confusion and maintains motivation.
#### Challenge States
```mermaid
stateDiagram-v2
[*] --> NotStarted: User views challenge
NotStarted --> Active: Start Challenge
Active --> Active: Daily completion
Active --> Failed: Rule violation
Active --> Completed: All days finished
Failed --> Active: Restart
Completed --> [*]: Challenge archived
Failed --> NotStarted: Abandon
```
#### Functional Requirements
| ID | Requirement | Priority |
| --- | ----------- | -------- |
| LM-01 | Track challenge state (not started, active, failed, completed) | Must Have |
| LM-02 | Display appropriate UI for each state | Must Have |
| LM-03 | Provide restart functionality with attempt history | Should Have |
| LM-04 | Show completion celebration on challenge finish | Should Have |
| LM-05 | Archive completed challenges with final stats | Should Have |
***
### Feature 4: User Onboarding & Account
**Purpose:** Get users into the app quickly while enabling data persistence.
**User Value:** Minimal friction to start while ensuring progress is never lost.
#### Functional Requirements
| ID | Requirement | Priority |
| --- | ----------- | -------- |
| UA-01 | Email/password authentication | Must Have |
| UA-02 | Social login (Apple, Google) | Should Have |
| UA-03 | Brief onboarding highlighting key features | Should Have |
| UA-04 | Profile with basic settings | Must Have |
| UA-05 | Notification preferences management | Should Have |
***
## MVP Scope Definition
### In Scope (MVP)
| Category | Included |
| -------- | -------- |
| Challenge Library | 5 curated challenges with complete requirements |
| Progress Tracking | Daily check-ins, streaks, progress visualization |
| Lifecycle | Start, track, complete, restart challenges |
| Account | Basic auth, profile, notification settings |
| Platform | iOS and Android via cross-platform framework |
### Out of Scope (Post-MVP)
| Feature | Phase | Rationale |
| ------- | ----- | --------- |
| Social/Friends | Phase 2 | Requires significant additional infrastructure |
| Leaderboards | Phase 2 | Depends on user base for meaningful competition |
| Custom Challenges | Phase 2 | Focus on curated quality first |
| Progress Photos | Phase 2 | Storage and privacy considerations |
| Community Forums | Phase 3 | Moderation requirements |
| Premium Subscriptions | Phase 2 | Establish value before monetization |
***
## Success Metrics & KPIs
### Primary Metrics
| Metric | Target | Measurement |
| ------ | ------ | ----------- |
| Day 7 Retention | \>40% | Users returning 7 days after install |
| Challenge Start Rate | \>60% | Users who start a challenge within first session |
| Daily Check-in Rate | \>70% | Active challenge users checking in daily |
| Challenge Completion Rate | \>15% | Users completing their started challenge |
### Secondary Metrics
| Metric | Target | Measurement |
| ------ | ------ | ----------- |
| App Store Rating | \>4\.5 stars | Average user rating |
| Session Duration | \>2 min | Average time per session |
| Streak Length | \>7 days avg | Average streak before break |
| Restart Rate | <50% | Users restarting after failure (lower = better initial success) |
### Tracking Implementation
```mermaid
graph LR
A[User Actions] --> B[Analytics Events]
B --> C[Challenge Started]
B --> D[Daily Check-in]
B --> E[Streak Milestone]
B --> F[Challenge Completed]
B --> G[Challenge Failed/Restart]
C & D & E & F & G --> H[Dashboard]
```
***
## User Flow Summary
```mermaid
graph TD
A[App Launch] --> B{Authenticated?}
B -->|No| C[Onboarding/Login]
B -->|Yes| D{Active Challenge?}
C --> D
D -->|No| E[Challenge Library]
D -->|Yes| F[Daily Dashboard]
E --> G[Challenge Details]
G --> H[Start Challenge]
H --> F
F --> I[Check Off Tasks]
I --> J{All Complete?}
J -->|Yes| K[Streak Updated]
J -->|No| L[Pending Tasks]
K --> M{Challenge Done?}
M -->|Yes| N[Completion Screen]
M -->|No| F
```
***
## Technical Considerations
### Data Requirements
* Challenge definitions (static, cacheable)
* User progress records (daily task completion)
* Streak calculations (derived from progress)
* User preferences and settings
### Offline Capability
* Challenge content available offline after initial load
* Daily check-ins queue when offline, sync when connected
* Progress data cached locally with cloud backup
### Notification Strategy
* Daily reminder at user-configured time
* Streak milestone celebrations (7, 14, 30, etc.)
* Gentle re-engagement after 2 days of inactivity
***
## Recommendations
1. **Prioritize Daily Experience**: The check-in flow is the core interaction—invest heavily in making it fast, satisfying, and visually rewarding.
2. **Nail the Streak Psychology**: Streaks are the primary motivation mechanism. Consider streak freezes or recovery options for user retention.
3. **Quality Over Quantity**: Launch with 5 well-documented challenges rather than 20 incomplete ones. Each challenge should feel professionally curated.
4. **Design for Failure Recovery**: Most users will fail demanding challenges. The restart experience should feel encouraging, not punishing.
5. **Build Analytics Foundation**: Implement comprehensive event tracking from day one to inform Phase 2 social features.

43
mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

50
mobile/README.md Normal file
View File

@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

48
mobile/app.json Normal file
View File

@ -0,0 +1,48 @@
{
"expo": {
"name": "mobile",
"slug": "mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@ -0,0 +1,35 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, RefreshControl } from 'react-native';
import { useRouter } from 'expo-router';
import { withObservables } from '@nozbe/watermelondb/react';
import { database } from '../../src/db';
import Challenge from '../../src/db/models/Challenge';
import { syncChallenges } from '../../src/db/sync';
import { Colors } from '@/constants/theme';
const ChallengeItem = ({ challenge, onPress }: { challenge: Challenge; onPress: () => void }) => (
<TouchableOpacity style={styles.card} onPress={onPress}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}>{challenge.name}</Text>
<View style={[styles.badge, { backgroundColor: getDifficultyColor(challenge.difficulty) }]}>
<Text style={styles.badgeText}>{challenge.difficulty}</Text>
</View>
</View>
<Text style={styles.cardDescription} numberOfLines={2}>
{challenge.description}
</Text>
<View style={styles.cardFooter}>
<Text style={styles.durationText}>{challenge.durationDays} Days</Text>
</View>
</TouchableOpacity>
);
const getDifficultyColor = (difficulty: string) => {
switch (difficulty?.toLowerCase()) {
case 'beginner': return '#4CAF50';
case 'moderate': return '#FF9800';
case 'hard': return '#F44336';
case 'extreme': return '#9C27B0';
default: return '#757575';
}
};
const ChallengeList = ({ challenges }: { challenges: Challenge[] }) => {
const router = useRouter();
const [refreshing, setRefreshing] = useState(false);
const onRefresh = async () => {
setRefreshing(true);
await syncChallenges();
setRefreshing(false);
};
useEffect(() => {
// Initial sync
syncChallenges();
}, []);
return (
<View style={styles.container}>
<Text style={styles.title}>Explore Challenges</Text>
<FlatList
data={challenges}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ChallengeItem
challenge={item}
onPress={() => router.push(`/challenge/${item.id}`)}
/>
)}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No challenges found.</Text>
<Text style={styles.emptySubText}>Pull to refresh to load challenges.</Text>
</View>
}
/>
</View>
);
};
const enhance = withObservables([], () => ({
challenges: database.get<Challenge>('challenges').query(),
}));
export default enhance(ChallengeList);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
paddingTop: 60, // Status bar spacing
},
title: {
fontSize: 28,
fontWeight: 'bold',
paddingHorizontal: 20,
marginBottom: 16,
color: '#333',
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 20,
},
card: {
backgroundColor: 'white',
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
flex: 1,
marginRight: 8,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
badgeText: {
color: 'white',
fontSize: 12,
fontWeight: 'bold',
textTransform: 'capitalize',
},
cardDescription: {
fontSize: 14,
color: '#666',
marginBottom: 12,
lineHeight: 20,
},
cardFooter: {
flexDirection: 'row',
alignItems: 'center',
},
durationText: {
fontSize: 14,
color: '#888',
fontWeight: '500',
},
emptyContainer: {
padding: 40,
alignItems: 'center',
},
emptyText: {
fontSize: 18,
color: '#666',
marginBottom: 8,
},
emptySubText: {
fontSize: 14,
color: '#999',
},
});

127
mobile/app/(tabs)/index.tsx Normal file
View File

@ -0,0 +1,127 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { withObservables } from '@nozbe/watermelondb/react';
import { database } from '../../src/db';
import UserChallenge from '../../src/db/models/UserChallenge';
import { Q } from '@nozbe/watermelondb';
import ProgressScreen from '../screens/ProgressScreen';
import { Colors } from '@/constants/theme';
const HomeScreen = ({ userChallenge }: { userChallenge: UserChallenge | null }) => {
const router = useRouter();
const handleReset = async () => {
Alert.alert(
"Reset Data",
"Are you sure you want to clear all local data? This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
try {
await database.write(async () => {
await database.unsafeResetDatabase();
});
// Reload or just let the UI update (observables should handle it, but reset might need reload)
// For now, unsafeResetDatabase might require app reload in some contexts,
// but let's see if observables pick up the empty state.
// Actually, unsafeResetDatabase clears everything.
} catch (e) {
console.error("Error resetting database:", e);
}
}
}
]
);
};
if (userChallenge) {
return (
<View style={styles.container}>
<ProgressScreen />
<TouchableOpacity style={styles.debugButton} onPress={handleReset}>
<Text style={styles.debugButtonText}>Debug: Reset Data</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.emptyContainer}>
<Text style={styles.title}>Welcome to Surge</Text>
<Text style={styles.subtitle}>You don{`'`}t have an active challenge yet.</Text>
<TouchableOpacity
style={styles.button}
onPress={() => router.push('/(tabs)/explore')}
>
<Text style={styles.buttonText}>Explore Challenges</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.debugButton} onPress={handleReset}>
<Text style={styles.debugButtonText}>Debug: Reset Data</Text>
</TouchableOpacity>
</View>
);
};
const enhance = withObservables([], () => ({
userChallenge: database.get<UserChallenge>('user_challenges')
.query(Q.where('status', 'active'))
.observeWithColumns(['status'])
.pipe(
// @ts-ignore
require('rxjs/operators').map((challenges: UserChallenge[]) => challenges.length > 0 ? challenges[0] : null)
),
}));
export default enhance(HomeScreen);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 10,
color: '#333',
},
subtitle: {
fontSize: 18,
color: '#666',
marginBottom: 40,
textAlign: 'center',
},
button: {
backgroundColor: Colors.light.tint,
paddingHorizontal: 30,
paddingVertical: 15,
borderRadius: 25,
marginBottom: 20,
},
buttonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
debugButton: {
marginTop: 20,
padding: 10,
},
debugButtonText: {
color: 'red',
fontSize: 14,
}
});

24
mobile/app/_layout.tsx Normal file
View File

@ -0,0 +1,24 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
export const unstable_settings = {
anchor: '(tabs)',
};
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}

View File

@ -0,0 +1,272 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { withObservables } from '@nozbe/watermelondb/react';
import { database } from '../../src/db';
import Challenge from '../../src/db/models/Challenge';
import ChallengeRequirement from '../../src/db/models/ChallengeRequirement';
import { syncChallengeDetails } from '../../src/db/sync';
import UserChallenge from '../../src/db/models/UserChallenge';
import User from '../../src/db/models/User';
const ChallengeDetailScreen = ({ challenge, requirements }: { challenge: Challenge; requirements: ChallengeRequirement[] }) => {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
useEffect(() => {
if (challenge) {
setLoading(true);
syncChallengeDetails(challenge.id).finally(() => setLoading(false));
}
}, [challenge]);
const handleStartChallenge = async () => {
if (!challenge) return;
setStarting(true);
try {
await database.write(async () => {
const userChallengesCollection = database.get<UserChallenge>('user_challenges');
// Check if already active
// Note: In a real app, we'd check for existing active challenges for this user
// For now, we'll just create a new one.
// We need a user ID. Since we don't have auth fully set up in this context,
// we'll use a placeholder or fetch the first user if available.
const usersCollection = database.get<User>('users');
const users = await usersCollection.query().fetch();
let userId = 'temp-user-id';
if (users.length > 0) {
userId = users[0].id;
} else {
// Create a temp user if none exists (for testing)
const newUser = await usersCollection.create(user => {
user.firebaseUid = 'temp-uid';
user.email = 'test@example.com';
});
userId = newUser.id;
}
await userChallengesCollection.create(uc => {
uc.user.id = userId;
uc.challenge.set(challenge);
uc.startDate = new Date().getTime();
uc.status = 'active';
uc.currentStreak = 0;
uc.longestStreak = 0;
uc.attemptNumber = 1;
uc.createdAt = new Date().getTime();
});
});
Alert.alert('Success', 'Challenge started!', [
{ text: 'OK', onPress: () => router.replace('/') }
]);
} catch (error) {
console.error('Error starting challenge:', error);
Alert.alert('Error', 'Failed to start challenge.');
} finally {
setStarting(false);
}
};
if (!challenge) {
return (
<View style={styles.loadingContainer}>
<Text>Loading challenge...</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{challenge.name}</Text>
<View style={styles.metaContainer}>
<View style={[styles.badge, { backgroundColor: '#E0E0E0' }]}>
<Text style={styles.badgeText}>{challenge.difficulty}</Text>
</View>
<Text style={styles.duration}>{challenge.durationDays} Days</Text>
</View>
</View>
<Text style={styles.description}>{challenge.description}</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Daily Requirements</Text>
{loading && requirements.length === 0 ? (
<ActivityIndicator size="small" color="#0000ff" />
) : (
requirements.map((req, index) => (
<View key={req.id} style={styles.requirementItem}>
<Text style={styles.reqIndex}>{index + 1}</Text>
<View style={styles.reqContent}>
<Text style={styles.reqTitle}>{req.title}</Text>
{req.description ? <Text style={styles.reqDesc}>{req.description}</Text> : null}
</View>
</View>
))
)}
</View>
<TouchableOpacity
style={[styles.startButton, starting && styles.disabledButton]}
onPress={handleStartChallenge}
disabled={starting}
>
{starting ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.startButtonText}>Start Challenge</Text>
)}
</TouchableOpacity>
</ScrollView>
);
};
const enhance = withObservables(['id'], ({ id }: { id: string }) => ({
challenge: database.get<Challenge>('challenges').findAndObserve(id),
requirements: database.get<ChallengeRequirement>('challenge_requirements')
.query(
// We can't easily query by relation in withObservables without Q.on
// But since we are syncing details which updates requirements,
// we can just query by challenge_id if we had the challenge object available immediately.
// However, 'challenge' prop is async.
// A common pattern is to pass the challenge itself or query requirements based on the ID prop.
).observeWithColumns(['title', 'description', 'sort_order'])
// Wait, the above query is empty. We need to filter by challenge_id.
// But we only have `id` (challenge id) from props.
}));
// Correct way to query related records with WatermelonDB observables
const enhanceWithRelated = withObservables(['id'], ({ id }: { id: string }) => {
const challengeObservable = database.get<Challenge>('challenges').findAndObserve(id);
// We need to return an object where keys are prop names and values are observables
return {
challenge: challengeObservable,
// To get requirements, we can't use `challenge.requirements` directly here because `challenge` is an observable, not the record yet.
// But we can query the requirements table directly using the ID.
requirements: database.get<ChallengeRequirement>('challenge_requirements')
.query(
// @ts-ignore
// We need to import Q from watermelondb
// But for now let's assume the query is correct
require('@nozbe/watermelondb').Q.where('challenge_id', id),
require('@nozbe/watermelondb').Q.sortBy('sort_order', require('@nozbe/watermelondb').Q.asc)
)
};
});
export default function ChallengeDetailWrapper() {
const { id } = useLocalSearchParams();
const ChallengeDetail = enhanceWithRelated(ChallengeDetailScreen);
// Ensure ID is a string
const challengeId = Array.isArray(id) ? id[0] : id;
if (!challengeId) return null;
return <ChallengeDetail id={challengeId} />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 10,
},
metaContainer: {
flexDirection: 'row',
alignItems: 'center',
},
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 15,
marginRight: 10,
},
badgeText: {
fontSize: 12,
fontWeight: 'bold',
color: '#555',
textTransform: 'uppercase',
},
duration: {
fontSize: 14,
color: '#666',
},
description: {
fontSize: 16,
color: '#444',
lineHeight: 24,
marginBottom: 30,
},
section: {
marginBottom: 30,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
marginBottom: 15,
color: '#333',
},
requirementItem: {
flexDirection: 'row',
marginBottom: 15,
backgroundColor: '#f9f9f9',
padding: 15,
borderRadius: 10,
},
reqIndex: {
fontSize: 16,
fontWeight: 'bold',
color: '#007AFF',
marginRight: 15,
width: 24,
},
reqContent: {
flex: 1,
},
reqTitle: {
fontSize: 16,
fontWeight: '500',
color: '#333',
marginBottom: 4,
},
reqDesc: {
fontSize: 14,
color: '#666',
},
startButton: {
backgroundColor: '#007AFF',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
marginBottom: 40,
},
disabledButton: {
opacity: 0.7,
},
startButtonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});

29
mobile/app/modal.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

6
mobile/app/progress.tsx Normal file
View File

@ -0,0 +1,6 @@
import React from 'react'
import ProgressScreen from './screens/ProgressScreen'
export default function ProgressRoute() {
return <ProgressScreen />
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import { View, Text, StyleSheet, Button } from 'react-native';
import { useNavigation } from '@react-navigation/native';
export default function ChallengeDetailScreen() {
const navigation = useNavigation<any>();
return (
<View style={styles.container}>
<Text style={styles.title}>Challenge Details</Text>
<Button
title="Go to Progress"
onPress={() => navigation.navigate('Progress')}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
marginBottom: 20,
},
});

View File

@ -0,0 +1,29 @@
import React from 'react';
import { View, Text, StyleSheet, Button } from 'react-native';
import { useNavigation } from '@react-navigation/native';
export default function ChallengeListScreen() {
const navigation = useNavigation<any>();
return (
<View style={styles.container}>
<Text style={styles.title}>Challenges</Text>
<Button
title="Go to Details"
onPress={() => navigation.navigate('ChallengeDetail')}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
marginBottom: 20,
},
});

View File

@ -0,0 +1,345 @@
import React, { useEffect, useState } from 'react'
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native'
import { withObservables } from '@nozbe/watermelondb/react'
import { Observable } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Q } from '@nozbe/watermelondb'
import { database } from '../../src/db/index'
import UserChallenge from '../../src/db/models/UserChallenge'
import DailyProgress from '../../src/db/models/DailyProgress'
import TaskCompletion from '../../src/db/models/TaskCompletion'
import { getActiveUserChallenge, getOrCreateDailyProgress, getOrCreateTaskCompletions, toggleTaskCompletion } from '../../src/utils/progress'
import { Colors } from '../../constants/theme'
import { IconSymbol } from '../../components/ui/icon-symbol'
interface TaskItemProps {
taskCompletion: TaskCompletion
onToggle: (task: TaskCompletion, isCompleted: boolean) => void
}
const TaskItem = withObservables(['taskCompletion'], ({ taskCompletion }) => ({
taskCompletion,
requirement: taskCompletion.requirement,
}))(({ taskCompletion, requirement, onToggle }: TaskItemProps & { requirement: any }) => {
const isCompleted = taskCompletion.completedAt > 0
return (
<TouchableOpacity
style={styles.taskItem}
onPress={() => onToggle(taskCompletion, !isCompleted)}
>
<View style={[styles.checkbox, isCompleted && styles.checkboxChecked]}>
{isCompleted && <IconSymbol name="checkmark" size={16} color="white" />}
</View>
<View style={styles.taskContent}>
<Text style={[styles.taskTitle, isCompleted && styles.taskTitleCompleted]}>
{requirement.title}
</Text>
{requirement.description && (
<Text style={styles.taskDescription}>{requirement.description}</Text>
)}
</View>
</TouchableOpacity>
)
})
interface ProgressScreenProps {
userChallenge: UserChallenge | null
dailyProgress: DailyProgress | null
taskCompletions: TaskCompletion[]
}
const ProgressScreen = ({ userChallenge, dailyProgress, taskCompletions }: ProgressScreenProps) => {
const [error, setError] = useState<string | null>(null)
console.log('ProgressScreen: render', {
hasUserChallenge: !!userChallenge,
hasDailyProgress: !!dailyProgress,
taskCompletionsCount: taskCompletions.length,
error
})
useEffect(() => {
const ensureProgress = async () => {
if (userChallenge) {
try {
setError(null)
console.log('ProgressScreen: ensuring progress for challenge', userChallenge.id)
const progress = await getOrCreateDailyProgress(userChallenge)
console.log('ProgressScreen: daily progress obtained', progress.id)
await getOrCreateTaskCompletions(progress)
console.log('ProgressScreen: task completions ensured')
} catch (e: any) {
console.error('Error ensuring daily progress:', e)
setError(e.message || 'Failed to load progress')
}
}
}
ensureProgress()
}, [userChallenge])
if (!userChallenge) {
return (
<View style={styles.centered}>
<Text style={styles.emptyText}>No active challenge found.</Text>
<Text style={styles.subText}>Go to the Explore tab to start a challenge!</Text>
</View>
)
}
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.emptyText}>Something went wrong</Text>
<Text style={styles.subText}>{error}</Text>
<TouchableOpacity onPress={() => setError(null)} style={{ marginTop: 20, padding: 10, backgroundColor: Colors.light.tint, borderRadius: 8 }}>
<Text style={{ color: 'white', fontWeight: 'bold' }}>Retry</Text>
</TouchableOpacity>
</View>
)
}
if (!dailyProgress) {
// If we have a user challenge but no daily progress yet, it might be creating it.
// However, if it takes too long or fails, we should probably show something else or retry.
// For now, let's just log that we are waiting.
console.log('ProgressScreen: waiting for dailyProgress')
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={Colors.light.tint} />
<Text style={{ marginTop: 10 }}>Loading today{`'`}s progress...</Text>
</View>
)
}
const completedCount = taskCompletions.filter(t => t.completedAt > 0).length
const totalCount = taskCompletions.length
const progressPercentage = totalCount > 0 ? completedCount / totalCount : 0
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.challengeName}>{userChallenge.challenge.name}</Text>
<Text style={styles.dayText}>Day {dailyProgress.dayNumber}</Text>
</View>
<View style={styles.progressContainer}>
<View style={styles.progressBarBackground}>
<View style={[styles.progressBarFill, { width: `${progressPercentage * 100}%` }]} />
</View>
<Text style={styles.progressText}>
{completedCount} of {totalCount} tasks completed
</Text>
</View>
{progressPercentage === 1 && (
<View style={styles.congratsContainer}>
<IconSymbol name="star.fill" size={40} color="#FFD700" />
<Text style={styles.congratsText}>Day Complete! Great Job!</Text>
</View>
)}
<FlatList
data={taskCompletions}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<TaskItem
taskCompletion={item}
onToggle={toggleTaskCompletion}
/>
)}
contentContainerStyle={styles.listContent}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
header: {
padding: 20,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
challengeName: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
},
dayText: {
fontSize: 18,
color: '#666',
marginTop: 4,
},
progressContainer: {
padding: 20,
backgroundColor: 'white',
marginBottom: 10,
},
progressBarBackground: {
height: 10,
backgroundColor: '#e0e0e0',
borderRadius: 5,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: Colors.light.tint,
},
progressText: {
marginTop: 8,
textAlign: 'right',
color: '#666',
},
listContent: {
padding: 16,
},
taskItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
checkbox: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: Colors.light.tint,
marginRight: 16,
justifyContent: 'center',
alignItems: 'center',
},
checkboxChecked: {
backgroundColor: Colors.light.tint,
},
taskContent: {
flex: 1,
},
taskTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
taskTitleCompleted: {
textDecorationLine: 'line-through',
color: '#999',
},
taskDescription: {
fontSize: 14,
color: '#666',
marginTop: 2,
},
emptyText: {
fontSize: 18,
fontWeight: 'bold',
color: '#333',
marginBottom: 8,
},
subText: {
fontSize: 16,
color: '#666',
textAlign: 'center',
},
congratsContainer: {
backgroundColor: '#E8F5E9',
margin: 20,
marginTop: 0,
padding: 15,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#4CAF50',
},
congratsText: {
fontSize: 18,
fontWeight: 'bold',
color: '#2E7D32',
marginLeft: 10,
},
})
const enhance = withObservables([], () => {
// We need to observe the active user challenge and today's progress
// This is a bit complex because we need to chain observables
// 1. Get active user challenge
const userChallenge$ = database.get<UserChallenge>('user_challenges')
.query(
Q.where('status', 'active')
)
.observeWithColumns(['status'])
.pipe(
map((challenges: UserChallenge[]) => challenges.length > 0 ? challenges[0] : null)
)
return {
userChallenge: userChallenge$,
dailyProgress: userChallenge$.pipe(
switchMap((uc: UserChallenge | null): Observable<DailyProgress | null> => {
if (!uc) return new Observable<DailyProgress | null>(observer => observer.next(null))
const today = new Date()
today.setHours(0,0,0,0)
const todayTs = today.getTime()
return database.get<DailyProgress>('daily_progress')
.query(
Q.where('user_challenge_id', uc.id),
Q.where('progress_date', todayTs)
)
.observe()
.pipe(
map(progresses => progresses.length > 0 ? progresses[0] : null)
)
})
),
taskCompletions: userChallenge$.pipe(
switchMap((uc: UserChallenge | null): Observable<TaskCompletion[]> => {
if (!uc) return new Observable<TaskCompletion[]>(observer => { observer.next([]); observer.complete() })
const today = new Date()
today.setHours(0,0,0,0)
const todayTs = today.getTime()
return database.get<DailyProgress>('daily_progress')
.query(
Q.where('user_challenge_id', uc.id),
Q.where('progress_date', todayTs)
)
.observe()
.pipe(
switchMap((progresses: DailyProgress[]): Observable<TaskCompletion[]> => {
const todayProgress = progresses.length > 0 ? progresses[0] : null
if (!todayProgress) return new Observable<TaskCompletion[]>(observer => { observer.next([]); observer.complete() })
return todayProgress.taskCompletions.observe()
})
)
})
) as Observable<TaskCompletion[]>
}
})
export default enhance(ProgressScreen)

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

10
mobile/babel.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
],
};
};

View File

@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@ -0,0 +1,19 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}

View File

@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View File

@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});

View File

@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

53
mobile/constants/theme.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});

10
mobile/eslint.config.js Normal file
View File

@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

12273
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
mobile/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.29.0",
"@expo/vector-icons": "^15.0.3",
"@nozbe/watermelondb": "^0.28.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@react-navigation/native-stack": "^7.12.0",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
},
"private": true
}

112
mobile/scripts/reset-project.js Executable file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

37
mobile/src/api/client.ts Normal file
View File

@ -0,0 +1,37 @@
import { Platform } from 'react-native';
// Use localhost for iOS simulator, 10.0.2.2 for Android emulator
// For physical device, use your machine's local IP address
const API_URL = Platform.select({
ios: 'http://localhost:3000',
android: 'http://10.0.2.2:3000',
default: 'http://localhost:3000',
});
export const apiClient = {
getChallenges: async () => {
try {
const response = await fetch(`${API_URL}/challenges`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return await response.json();
} catch (error) {
console.error('Error fetching challenges:', error);
throw error;
}
},
getChallengeDetails: async (id: string) => {
try {
const response = await fetch(`${API_URL}/challenges/${id}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return await response.json();
} catch (error) {
console.error(`Error fetching challenge details for ${id}:`, error);
throw error;
}
},
};

34
mobile/src/db/index.ts Normal file
View File

@ -0,0 +1,34 @@
import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'
import { Platform } from 'react-native'
import { schema } from './schema'
import User from './models/User'
import Challenge from './models/Challenge'
import ChallengeRequirement from './models/ChallengeRequirement'
import UserChallenge from './models/UserChallenge'
import DailyProgress from './models/DailyProgress'
import TaskCompletion from './models/TaskCompletion'
const adapter = new SQLiteAdapter({
schema,
// (You might want to comment out migrations for now if you haven't created them)
// migrations,
jsi: Platform.OS === 'ios',
onSetUpError: (error: any) => {
// Database failed to load -- offer the user to reload the app or log out
console.error('Database setup error:', error)
}
})
export const database = new Database({
adapter,
modelClasses: [
User,
Challenge,
ChallengeRequirement,
UserChallenge,
DailyProgress,
TaskCompletion,
],
})

View File

@ -0,0 +1,32 @@
import { Database } from '@nozbe/watermelondb'
import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
import { schema } from './schema'
import User from './models/User'
import Challenge from './models/Challenge'
import ChallengeRequirement from './models/ChallengeRequirement'
import UserChallenge from './models/UserChallenge'
import DailyProgress from './models/DailyProgress'
import TaskCompletion from './models/TaskCompletion'
const adapter = new LokiJSAdapter({
schema,
// migrations,
useWebWorker: false,
useIncrementalIndexedDB: true,
onSetUpError: (error: any) => {
console.error('Database setup error:', error)
}
})
export const database = new Database({
adapter,
modelClasses: [
User,
Challenge,
ChallengeRequirement,
UserChallenge,
DailyProgress,
TaskCompletion,
],
})

View File

@ -0,0 +1,22 @@
import { Model } from '@nozbe/watermelondb'
import { field, date, text, json, children } from '@nozbe/watermelondb/decorators'
import ChallengeRequirement from './ChallengeRequirement'
export default class Challenge extends Model {
static table = 'challenges'
static associations = {
challenge_requirements: { type: 'has_many', foreignKey: 'challenge_id' },
} as const
@text('name') name!: string
@text('slug') slug!: string
@text('description') description?: string
@field('duration_days') durationDays!: number
@text('difficulty') difficulty!: string
@field('is_active') isActive!: boolean
@json('metadata', (raw) => raw) metadata?: any
@date('created_at') createdAt!: number
@children('challenge_requirements') requirements!: any
}

View File

@ -0,0 +1,18 @@
import { Model } from '@nozbe/watermelondb'
import { field, text, json, relation } from '@nozbe/watermelondb/decorators'
import Challenge from './Challenge'
export default class ChallengeRequirement extends Model {
static table = 'challenge_requirements'
static associations = {
challenges: { type: 'belongs_to', key: 'challenge_id' },
} as const
@relation('challenges', 'challenge_id') challenge!: any
@text('title') title!: string
@text('description') description?: string
@text('type') type!: string
@json('validation_rules', (raw) => raw) validationRules?: any
@field('sort_order') sortOrder!: number
}

View File

@ -0,0 +1,22 @@
import { Model } from '@nozbe/watermelondb'
import { field, date, relation, children } from '@nozbe/watermelondb/decorators'
import UserChallenge from './UserChallenge'
export default class DailyProgress extends Model {
static table = 'daily_progress'
static associations = {
user_challenges: { type: 'belongs_to', key: 'user_challenge_id' },
task_completions: { type: 'has_many', foreignKey: 'daily_progress_id' },
} as const
@relation('user_challenges', 'user_challenge_id') userChallenge!: any
@date('progress_date') progressDate!: number
@field('day_number') dayNumber!: number
@field('is_complete') isComplete!: boolean
@date('completed_at') completedAt?: number
@date('created_at') createdAt!: number
@date('updated_at') updatedAt!: number
@children('task_completions') taskCompletions!: any
}

View File

@ -0,0 +1,19 @@
import { Model } from '@nozbe/watermelondb'
import { field, date, json, relation } from '@nozbe/watermelondb/decorators'
import DailyProgress from './DailyProgress'
import ChallengeRequirement from './ChallengeRequirement'
export default class TaskCompletion extends Model {
static table = 'task_completions'
static associations = {
daily_progress: { type: 'belongs_to', key: 'daily_progress_id' },
challenge_requirements: { type: 'belongs_to', key: 'requirement_id' },
} as const
@relation('daily_progress', 'daily_progress_id') dailyProgress!: any
@relation('challenge_requirements', 'requirement_id') requirement!: any
@json('completion_data', (raw) => raw) completionData?: any
@date('completed_at') completedAt!: number
@date('created_at') createdAt!: number
}

View File

@ -0,0 +1,12 @@
import { Model } from '@nozbe/watermelondb'
import { field, date, text } from '@nozbe/watermelondb/decorators'
export default class User extends Model {
static table = 'users'
@text('firebase_uid') firebaseUid!: string
@text('email') email!: string
@text('display_name') displayName?: string
@date('created_at') createdAt!: number
@date('updated_at') updatedAt!: number
}

View File

@ -0,0 +1,26 @@
import { Model } from '@nozbe/watermelondb'
import { field, date, text, relation, children } from '@nozbe/watermelondb/decorators'
import User from './User'
import Challenge from './Challenge'
export default class UserChallenge extends Model {
static table = 'user_challenges'
static associations = {
users: { type: 'belongs_to', key: 'user_id' },
challenges: { type: 'belongs_to', key: 'challenge_id' },
daily_progress: { type: 'has_many', foreignKey: 'user_challenge_id' },
} as const
@relation('users', 'user_id') user!: any
@relation('challenges', 'challenge_id') challenge!: any
@date('start_date') startDate!: number
@text('status') status!: string
@field('current_streak') currentStreak!: number
@field('longest_streak') longestStreak!: number
@field('attempt_number') attemptNumber!: number
@date('created_at') createdAt!: number
@date('updated_at') updatedAt!: number
@children('daily_progress') dailyProgress!: any
}

78
mobile/src/db/schema.ts Normal file
View File

@ -0,0 +1,78 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'users',
columns: [
{ name: 'firebase_uid', type: 'string', isIndexed: true },
{ name: 'email', type: 'string', isIndexed: true },
{ name: 'display_name', type: 'string', isOptional: true },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'challenges',
columns: [
{ name: 'name', type: 'string' },
{ name: 'slug', type: 'string', isIndexed: true },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'duration_days', type: 'number' },
{ name: 'difficulty', type: 'string' },
{ name: 'is_active', type: 'boolean', isIndexed: true },
{ name: 'metadata', type: 'string', isOptional: true }, // JSON stringified
{ name: 'created_at', type: 'number' },
],
}),
tableSchema({
name: 'challenge_requirements',
columns: [
{ name: 'challenge_id', type: 'string', isIndexed: true },
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'type', type: 'string' },
{ name: 'validation_rules', type: 'string', isOptional: true }, // JSON stringified
{ name: 'sort_order', type: 'number' },
{ name: 'created_at', type: 'number' },
],
}),
tableSchema({
name: 'user_challenges',
columns: [
{ name: 'user_id', type: 'string', isIndexed: true },
{ name: 'challenge_id', type: 'string', isIndexed: true },
{ name: 'start_date', type: 'number' },
{ name: 'status', type: 'string', isIndexed: true },
{ name: 'current_streak', type: 'number' },
{ name: 'longest_streak', type: 'number' },
{ name: 'attempt_number', type: 'number' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'daily_progress',
columns: [
{ name: 'user_challenge_id', type: 'string', isIndexed: true },
{ name: 'progress_date', type: 'number' },
{ name: 'day_number', type: 'number' },
{ name: 'is_complete', type: 'boolean' },
{ name: 'completed_at', type: 'number', isOptional: true },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'task_completions',
columns: [
{ name: 'daily_progress_id', type: 'string', isIndexed: true },
{ name: 'requirement_id', type: 'string', isIndexed: true },
{ name: 'completion_data', type: 'string', isOptional: true }, // JSON stringified
{ name: 'completed_at', type: 'number' },
{ name: 'created_at', type: 'number' },
],
}),
],
})

121
mobile/src/db/sync.ts Normal file
View File

@ -0,0 +1,121 @@
import { database } from './index';
import { apiClient } from '../api/client';
import Challenge from './models/Challenge';
import ChallengeRequirement from './models/ChallengeRequirement';
import { Q } from '@nozbe/watermelondb';
export const syncChallenges = async () => {
try {
const challengesData = await apiClient.getChallenges();
await database.write(async () => {
const challengesCollection = database.get<Challenge>('challenges');
for (const challengeData of challengesData) {
const existingChallenges = await challengesCollection.query(
Q.where('slug', challengeData.slug)
).fetch();
if (existingChallenges.length > 0) {
// Update existing
const challenge = existingChallenges[0];
await challenge.update(record => {
record.name = challengeData.name;
record.description = challengeData.description;
record.durationDays = challengeData.duration_days;
record.difficulty = challengeData.difficulty;
record.isActive = challengeData.is_active;
record.metadata = challengeData.metadata;
});
} else {
// Create new
await challengesCollection.create(record => {
record._raw.id = challengeData.id;
record.name = challengeData.name;
record.slug = challengeData.slug;
record.description = challengeData.description;
record.durationDays = challengeData.duration_days;
record.difficulty = challengeData.difficulty;
record.isActive = challengeData.is_active;
record.metadata = challengeData.metadata;
record.createdAt = new Date().getTime();
});
}
}
});
console.log('Challenges synced successfully');
} catch (error) {
console.error('Error syncing challenges:', error);
}
};
export const syncChallengeDetails = async (challengeId: string) => {
try {
const challengeData = await apiClient.getChallengeDetails(challengeId);
await database.write(async () => {
const challengesCollection = database.get<Challenge>('challenges');
const requirementsCollection = database.get<ChallengeRequirement>('challenge_requirements');
// 1. Update Challenge
const existingChallenges = await challengesCollection.query(
Q.where('id', challengeData.id)
).fetch();
let challengeRecord: Challenge;
if (existingChallenges.length > 0) {
challengeRecord = existingChallenges[0];
await challengeRecord.update(record => {
record.name = challengeData.name;
record.description = challengeData.description;
record.durationDays = challengeData.duration_days;
record.difficulty = challengeData.difficulty;
record.isActive = challengeData.is_active;
record.metadata = challengeData.metadata;
});
} else {
challengeRecord = await challengesCollection.create(record => {
record._raw.id = challengeData.id;
record.name = challengeData.name;
record.slug = challengeData.slug;
record.description = challengeData.description;
record.durationDays = challengeData.duration_days;
record.difficulty = challengeData.difficulty;
record.isActive = challengeData.is_active;
record.metadata = challengeData.metadata;
record.createdAt = new Date().getTime();
});
}
// 2. Update Requirements
// Delete existing requirements for this challenge and re-create them (simplest sync strategy for sub-items)
const existingRequirements = await requirementsCollection.query(
Q.where('challenge_id', challengeRecord.id)
).fetch();
for (const req of existingRequirements) {
await req.markAsDeleted();
await req.destroyPermanently();
}
if (challengeData.requirements && Array.isArray(challengeData.requirements)) {
for (const reqData of challengeData.requirements) {
await requirementsCollection.create(record => {
record._raw.id = reqData.id;
record.challenge.set(challengeRecord); // Set relation
record.title = reqData.title;
record.description = reqData.description;
record.type = reqData.type;
record.validationRules = reqData.validation_rules;
record.sortOrder = reqData.sort_order;
});
}
}
});
console.log(`Challenge ${challengeId} details synced successfully`);
} catch (error) {
console.error(`Error syncing challenge details for ${challengeId}:`, error);
}
}

View File

@ -0,0 +1,149 @@
import { Q } from '@nozbe/watermelondb'
import { database } from '../db/index'
import UserChallenge from '../db/models/UserChallenge'
import DailyProgress from '../db/models/DailyProgress'
import TaskCompletion from '../db/models/TaskCompletion'
import ChallengeRequirement from '../db/models/ChallengeRequirement'
export const getActiveUserChallenge = async () => {
const userChallenges = await database.get<UserChallenge>('user_challenges')
.query(Q.where('status', 'active'))
.fetch()
return userChallenges.length > 0 ? userChallenges[0] : null
}
export const getDayNumber = (startDate: number) => {
const start = new Date(startDate)
const now = new Date()
start.setHours(0, 0, 0, 0)
now.setHours(0, 0, 0, 0)
const diffTime = now.getTime() - start.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
return diffDays + 1
}
export const getOrCreateDailyProgress = async (userChallenge: UserChallenge) => {
console.log('getOrCreateDailyProgress: start', { userChallengeId: userChallenge.id })
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayTimestamp = today.getTime()
const dayNumber = getDayNumber(userChallenge.startDate)
console.log('getOrCreateDailyProgress: calculated date', { todayTimestamp, dayNumber })
if (isNaN(dayNumber)) {
throw new Error(`Invalid start date for challenge: ${userChallenge.startDate}`)
}
const existingProgress = await database.get<DailyProgress>('daily_progress')
.query(
Q.where('user_challenge_id', userChallenge.id),
Q.where('progress_date', todayTimestamp)
)
.fetch()
console.log('getOrCreateDailyProgress: existing search result', { count: existingProgress.length })
if (existingProgress.length > 0) {
return existingProgress[0]
}
let newProgress: DailyProgress | null = null
try {
await database.write(async () => {
console.log('getOrCreateDailyProgress: creating new progress')
newProgress = await database.get<DailyProgress>('daily_progress').create(progress => {
progress.userChallenge.set(userChallenge)
progress.progressDate = todayTimestamp
progress.dayNumber = dayNumber
progress.isComplete = false
})
console.log('getOrCreateDailyProgress: created', { newProgressId: newProgress.id })
})
} catch (e) {
console.error('getOrCreateDailyProgress: error creating progress', e)
throw e
}
return newProgress!
}
export const getOrCreateTaskCompletions = async (dailyProgress: DailyProgress) => {
console.log('getOrCreateTaskCompletions: start', { dailyProgressId: dailyProgress.id })
const userChallenge = await dailyProgress.userChallenge.fetch()
const challenge = await userChallenge.challenge.fetch()
const requirements = await database.get<ChallengeRequirement>('challenge_requirements')
.query(Q.where('challenge_id', challenge.id))
.fetch()
console.log('getOrCreateTaskCompletions: requirements found', { count: requirements.length })
const existingCompletions = await database.get<TaskCompletion>('task_completions')
.query(Q.where('daily_progress_id', dailyProgress.id))
.fetch()
console.log('getOrCreateTaskCompletions: existing completions', { count: existingCompletions.length })
const existingRequirementIds = new Set(existingCompletions.map(tc => tc.requirement.id))
const missingRequirements = requirements.filter(req => !existingRequirementIds.has(req.id))
console.log('getOrCreateTaskCompletions: missing requirements', { count: missingRequirements.length })
if (missingRequirements.length > 0) {
try {
await database.write(async () => {
for (const req of missingRequirements) {
await database.get<TaskCompletion>('task_completions').create(completion => {
completion.dailyProgress.set(dailyProgress)
completion.requirement.set(req)
completion.completedAt = 0
})
}
})
console.log('getOrCreateTaskCompletions: created missing completions')
} catch (e) {
console.error('getOrCreateTaskCompletions: error creating completions', e)
throw e
}
}
return await database.get<TaskCompletion>('task_completions')
.query(Q.where('daily_progress_id', dailyProgress.id))
.fetch()
}
export const toggleTaskCompletion = async (taskCompletion: TaskCompletion, isCompleted: boolean) => {
await database.write(async () => {
await taskCompletion.update(tc => {
tc.completedAt = isCompleted ? Date.now() : 0
})
const dailyProgress = await taskCompletion.dailyProgress.fetch()
const allCompletions = await dailyProgress.taskCompletions.fetch()
const allDone = allCompletions.every((tc: TaskCompletion) => tc.id === taskCompletion.id ? isCompleted : tc.completedAt > 0)
if (dailyProgress.isComplete !== allDone) {
await dailyProgress.update((dp: DailyProgress) => {
dp.isComplete = allDone
dp.completedAt = allDone ? Date.now() : undefined
})
const userChallenge = await dailyProgress.userChallenge.fetch()
await userChallenge.update((uc: UserChallenge) => {
if (allDone) {
uc.currentStreak += 1
if (uc.currentStreak > uc.longestStreak) {
uc.longestStreak = uc.currentStreak
}
} else {
uc.currentStreak = Math.max(0, uc.currentStreak - 1)
}
})
}
})
}

21
mobile/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

6827
mobile/yarn.lock Normal file

File diff suppressed because it is too large Load Diff