Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test
44 changes: 23 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Planned

- Push notifications for focus reminders and streak alerts
- - Widget support (iOS/Android home screen)
- - Social leaderboard with friends
- - Multiple monster companions to collect
- - Export focus history to CSV

- ---

## [1.0.0] - 2026-05-01

### Added
- Initial release of FocusMon
- - Pomodoro-style focus sessions with completion and give-up outcomes
- - XP, levels, focus coins, streaks, and total focus-hour tracking
- - Monster companion with level-based evolution art
- - Quest, journal, profile, creature, vault, and leaderboard screens
- - Offline persistence with AsyncStorage
- - Optional Supabase Auth and Postgres sync
- - Animated timer, XP, and level-up moments with Reanimated
- - Built with React Native + Expo 56 + TypeScript
- - NativeWind + Tailwind CSS for styling
- - Zustand state stores for user, creature, and session data
- Widget support (iOS/Android home screen)
- Social leaderboard with friends
- Multiple monster companions to collect
- Export focus history to CSV

---

## [1.0.0] - 2026-05-01

### Added

- Initial release of FocusMon
- Pomodoro-style focus sessions with completion and give-up outcomes
- XP, levels, focus coins, streaks, and total focus-hour tracking
- Monster companion with level-based evolution art
- Quest, journal, profile, creature, vault, and leaderboard screens
- Offline persistence with AsyncStorage
- Optional Supabase Auth and Postgres sync
- Animated timer, XP, and level-up moments with Reanimated
- Built with React Native + Expo 56 + TypeScript
- NativeWind + Tailwind CSS for styling
- Zustand state stores for user, creature, and session data
83 changes: 43 additions & 40 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,46 @@ Thanks for your interest in contributing to FocusMon! 🎯
## Getting Started

1. **Fork** the repo and clone it locally
2. 2. **Install dependencies**:
3. ```bash
npm install
cp .env.example .env
```
3. **Run the app**:
4. ```bash
npm start # Expo Go (scan QR)
npm run ios # iOS simulator
npm run android # Android emulator
```

> Supabase is optional — the app works fully offline with AsyncStorage. To enable sync, create a Supabase project, run `supabase/schema.sql`, and add your URL + anon key to `.env`.
>
> ## Good First Contributions
>
> - **New monster evolutions** — add art and leveling thresholds
> - - **Push notifications** — focus reminders and streak alerts
> - - **Bug fixes** — check open [issues](https://github.com/sonnymay/focusomon/issues)
> - - **Tests** — unit tests for XP calculation, level logic, streak tracking
> - - **Docs** — improve README setup steps or add screenshots
>
> - ## Pull Request Guidelines
>
> - 1. Branch off `main`: `git checkout -b feat/your-feature`
> 2. 2. Keep PRs focused — one feature or fix per PR
> 3. 3. Follow existing patterns: functional components, Zustand stores, NativeWind styling
> 4. 4. Run `npx tsc` to check for TypeScript errors before submitting
> 5. 5. Describe what you changed and why in the PR description
>
> 6. ## Code Style
>
> 7. - **TypeScript** strict mode — no `any` without justification
> - - **NativeWind** for styling — avoid inline StyleSheet where possible
> - - **Zustand** for state — keep stores small and focused
> - - **Commits**: Conventional commits (`feat:`, `fix:`, `chore:`, `docs:`)
>
> - ## Questions?
>
> - Open an issue or reach out at sonnymaywi@gmail.com.
2. **Install dependencies**:

```bash
npm install
cp .env.example .env
```

3. **Run the app**:

```bash
npm start # Expo Go (scan QR)
npm run ios # iOS simulator
npm run android # Android emulator
```

> Supabase is optional — the app works fully offline with AsyncStorage. To enable sync, create a Supabase project, run `supabase/schema.sql`, and add your URL + anon key to `.env`.

## Good First Contributions

- **New monster evolutions** — add art and leveling thresholds
- **Push notifications** — focus reminders and streak alerts
- **Bug fixes** — check open [issues](https://github.com/sonnymay/focusomon/issues)
- **Tests** — unit tests for XP calculation, level logic, streak tracking
- **Docs** — improve README setup steps or add screenshots

## Pull Request Guidelines

1. Branch off `main`: `git checkout -b feat/your-feature`
2. Keep PRs focused — one feature or fix per PR
3. Follow existing patterns: functional components, Zustand stores, NativeWind styling
4. Run `npm run typecheck` and `npm test` before submitting
5. Describe what you changed and why in the PR description

## Code Style

- **TypeScript** strict mode — no `any` without justification
- **NativeWind** for styling — avoid inline StyleSheet where possible
- **Zustand** for state — keep stores small and focused
- **Commits**: Conventional commits (`feat:`, `fix:`, `chore:`, `docs:`)

## Questions?

Open an issue or reach out at sonnymaywi@gmail.com.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# FocusMon

[![CI](https://github.com/sonnymay/focusomon/actions/workflows/ci.yml/badge.svg)](https://github.com/sonnymay/focusomon/actions/workflows/ci.yml)
![React Native](https://img.shields.io/badge/React_Native-0.85-61DAFB?style=flat-square&logo=react&logoColor=black)
![Expo](https://img.shields.io/badge/Expo-56-000020?style=flat-square&logo=expo&logoColor=white)
![Supabase](https://img.shields.io/badge/Supabase-optional-3ECF8E?style=flat-square&logo=supabase&logoColor=white)
Expand Down Expand Up @@ -34,7 +35,7 @@ FocusMon is an offline-first focus timer with RPG progression. Complete Pomodoro

- Mobile app structure with Expo Router and typed React Native screens
- Offline-first state design with an optional backend sync path
- Small, testable game logic for XP, levels, streaks, and creature stats
- Small, unit-tested game logic for XP, levels, streaks, and creature stats
- Clean separation between UI components, stores, and shared logic
- Practical product thinking around retention loops, rewards, and progress feedback

Expand All @@ -58,3 +59,14 @@ npm run web

Supabase is optional for local use. With blank `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY`, the app stores progress locally with AsyncStorage. To enable sync, create a Supabase project, run `supabase/schema.sql`, enable anonymous auth, and add the project URL and anon key to `.env`.

## Testing

The game logic in `lib/` (XP curves, level thresholds, streak rules, egg pulls, creature stats) is pure TypeScript with no React Native dependencies, so it runs under Node's built-in test runner with zero extra test dependencies:

```bash
npm test # unit tests (node:test)
npm run typecheck # tsc --noEmit
```

Both run in CI on every push and pull request.

146 changes: 146 additions & 0 deletions lib/gameLogic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
XP_TABLE,
MAX_LEVEL,
xpForDuration,
applyDailyGoalBonus,
penaltyXp,
levelFromXp,
pullEgg,
computeStreak,
streakBroken,
statsForLevel,
LINEAGES,
EGG_COSTS,
} from './gameLogic.ts';

describe('xpForDuration', () => {
it('rewards scale with session length', () => {
assert.deepEqual(xpForDuration(15), { xp: 100, coins: 10 });
assert.deepEqual(xpForDuration(60), { xp: 600, coins: 60 });
assert.ok(xpForDuration(30).xp < xpForDuration(45).xp);
});
});

describe('applyDailyGoalBonus', () => {
it('adds 25% when the daily goal is hit', () => {
assert.equal(applyDailyGoalBonus(100, true), 125);
assert.equal(applyDailyGoalBonus(100, false), 100);
});

it('rounds to the nearest whole XP', () => {
assert.equal(applyDailyGoalBonus(250, true), 313);
});
});

describe('penaltyXp', () => {
it('is 20% of the session XP, floored', () => {
assert.equal(penaltyXp(100), 20);
assert.equal(penaltyXp(101), 20);
assert.equal(penaltyXp(0), 0);
});
});

describe('levelFromXp', () => {
it('starts at level 1 with zero XP', () => {
const r = levelFromXp(0);
assert.equal(r.level, 1);
assert.equal(r.xpIntoLevel, 0);
assert.equal(r.progress, 0);
});

it('levels up exactly at each XP threshold', () => {
assert.equal(levelFromXp(299).level, 1);
assert.equal(levelFromXp(300).level, 2);
assert.equal(levelFromXp(700).level, 3);
});

it('tracks progress within a level', () => {
// Level 1 spans 0..300
const r = levelFromXp(150);
assert.equal(r.level, 1);
assert.equal(r.xpIntoLevel, 150);
assert.equal(r.xpToNext, 300);
assert.equal(r.progress, 0.5);
});

it('caps at MAX_LEVEL with full progress', () => {
const top = XP_TABLE[XP_TABLE.length - 1];
const r = levelFromXp(top + 999999);
assert.equal(r.level, MAX_LEVEL);
assert.equal(r.progress, 1);
});
});

describe('pullEgg', () => {
it('always returns a valid rarity for every tier', () => {
const rarities = new Set(['Common', 'Rare', 'Epic', 'Legendary']);
for (const tier of Object.keys(EGG_COSTS) as Array<keyof typeof EGG_COSTS>) {
for (let i = 0; i < 500; i++) {
assert.ok(rarities.has(pullEgg(tier)));
}
}
});
});

describe('computeStreak', () => {
const now = new Date(2026, 5, 15, 12, 0, 0);

it('starts a streak on the first ever session', () => {
assert.equal(computeStreak(null, 0, now), 1);
});

it('keeps the streak for a second session on the same day', () => {
const sameDay = new Date(2026, 5, 15, 8, 0, 0).toISOString();
assert.equal(computeStreak(sameDay, 3, now), 3);
});

it('increments the streak on consecutive days', () => {
const yesterday = new Date(2026, 5, 14, 23, 0, 0).toISOString();
assert.equal(computeStreak(yesterday, 3, now), 4);
});

it('resets the streak after a missed day', () => {
const twoDaysAgo = new Date(2026, 5, 13, 12, 0, 0).toISOString();
assert.equal(computeStreak(twoDaysAgo, 7, now), 1);
});
});

describe('streakBroken', () => {
const now = new Date(2026, 5, 15, 12, 0, 0);

it('is not broken with no prior session', () => {
assert.equal(streakBroken(null, now), false);
});

it('is not broken after yesterday, broken after a gap', () => {
const yesterday = new Date(2026, 5, 14, 1, 0, 0).toISOString();
const threeDaysAgo = new Date(2026, 5, 12, 1, 0, 0).toISOString();
assert.equal(streakBroken(yesterday, now), false);
assert.equal(streakBroken(threeDaysAgo, now), true);
});
});

describe('statsForLevel', () => {
it('grows combat stats linearly with level', () => {
for (const lineage of LINEAGES) {
const l1 = statsForLevel(1, lineage);
const l5 = statsForLevel(5, lineage);
assert.equal(l5.atk, l1.atk + 12);
assert.equal(l5.def, l1.def + 8);
assert.equal(l5.hp, l1.hp + 32);
}
});

it('never lets focus stats exceed 100', () => {
for (let level = 1; level <= MAX_LEVEL; level++) {
const r = statsForLevel(level, 'Fire');
assert.ok(r.focusPower <= 100);
assert.ok(r.discipline <= 100);
assert.ok(r.intensity <= 100);
}
// Intensity starts highest, so it is the first stat to hit the cap
assert.equal(statsForLevel(MAX_LEVEL, 'Fire').intensity, 100);
});
});
46 changes: 46 additions & 0 deletions lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { formatTime, clamp, randomName } from './utils.ts';
import { generateTrainerId } from './trainerId.ts';

describe('formatTime', () => {
it('formats seconds as MM:SS', () => {
assert.equal(formatTime(0), '00:00');
assert.equal(formatTime(59), '00:59');
assert.equal(formatTime(60), '01:00');
assert.equal(formatTime(1500), '25:00');
});

it('clamps negative values to zero', () => {
assert.equal(formatTime(-5), '00:00');
});

it('floors fractional seconds', () => {
assert.equal(formatTime(89.9), '01:29');
});
});

describe('clamp', () => {
it('keeps values inside the range', () => {
assert.equal(clamp(5, 0, 10), 5);
assert.equal(clamp(-1, 0, 10), 0);
assert.equal(clamp(11, 0, 10), 10);
});
});

describe('randomName', () => {
it('builds a name from a prefix and suffix', () => {
for (let i = 0; i < 50; i++) {
const name = randomName('Fire');
assert.match(name, /^[A-Z][a-z]{2}(mon|rax|gax|shi|kor|lyn|vex)$/);
}
});
});

describe('generateTrainerId', () => {
it('matches the #RKM-XXXX format without ambiguous characters', () => {
for (let i = 0; i < 50; i++) {
assert.match(generateTrainerId(), /^#RKM-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/);
}
});
});
Loading
Loading