init
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ProgressScreen from './screens/ProgressScreen'
|
||||||
|
|
||||||
|
export default function ProgressRoute() {
|
||||||
|
return <ProgressScreen />
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -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 }],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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/*'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { useColorScheme } from 'react-native';
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||