Skip to content
Draft
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
123 changes: 123 additions & 0 deletions docs/superpowers/specs/2026-04-06-open-source-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Open Source Geni — Design Spec

**Date:** 2026-04-06
**Author:** Yago Martinez
**License:** MIT
**Goal:** Make the Geni iOS app + marketing site fully open source — transparent, contributor-ready, and useful as a reference project.

---

## Context

Geni is a published iOS reading/learning app for kids (App Store ID: 6761134405). The repo is currently private on GitHub. The goal is to open source it for three reasons: transparency, community contributions, and as a reference for other developers. The app has zero external dependencies (pure SwiftUI + native Apple frameworks), making it a clean codebase to share.

---

## Approach

**Standard open source setup (Option B):** Security cleanup + LICENSE + README + CONTRIBUTING + issue templates + Code of Conduct. No CI for now — can be added later.

---

## Section 1: Security Cleanup

**Files to remove from working tree and add to `.gitignore`:**
- `.asc/config.json` — contains App Store Connect API key ID and issuer ID
- `ios/profiles/*.mobileprovision` — provisioning profile
- `ios/ExportOptions.plist` — contains Apple Team ID (`ZBRDR5Q455`)
- `.wrangler/` — Cloudflare Workers local state

**Git history:** Check if `.asc/config.json` was ever committed. If yes, purge with `git filter-repo --path .asc/config.json --invert-paths`.

**`.gitignore` additions:**
```
# App Store Connect
.asc/config.json

# Signing
ios/profiles/*.mobileprovision
ios/ExportOptions.plist

# Cloudflare
.wrangler/
```

---

## Section 2: LICENSE

Create `LICENSE` at repo root:
- Type: MIT
- Copyright: `Copyright 2026 Yago Martinez`

---

## Section 3: README.md

Location: repo root
Sections:
1. App name + one-line description + App Store badge
2. **What is Geni?** — reading/learning app for kids
3. **Features** — key feature list
4. **Privacy** — zero-backend, all local storage (CloudKit/iCloud), no custom servers
5. **Tech stack** — SwiftUI, CloudKit, Speech framework, AVFoundation, native only
6. **Building locally** — Xcode requirements, clone + open `.xcodeproj`, run on simulator/device
7. **Project structure** — `ios/Geni/{Views,ViewModels,Services,Models}`, `site/`
8. **Contributing** — link to `CONTRIBUTING.md`
9. **License** — MIT

---

## Section 4: CONTRIBUTING.md

Location: repo root
Sections:
- How to report bugs → GitHub Issues (bug template)
- How to suggest features → GitHub Issues (feature template)
- Dev setup: Xcode (no dependencies, just open `.xcodeproj`)
- PR process: fork → feature branch → PR against `main`
- Code style: follow existing SwiftUI/MVVM patterns in codebase

---

## Section 5: GitHub Issue Templates

Location: `.github/ISSUE_TEMPLATE/`

**`bug_report.md`:**
- Steps to reproduce
- Expected vs actual behavior
- iOS version + device model
- Xcode version (if building from source)

**`feature_request.md`:**
- Problem description
- Proposed solution
- Alternatives considered

---

## Section 6: Code of Conduct

Location: `CODE_OF_CONDUCT.md`
Standard: Contributor Covenant v2.1
Enforcement contact: `hello@geni.kids`

---

## Section 7: Go Public

1. Commit all new files: `"Open source Geni under MIT license"`
2. Flip GitHub repo from private to public (done manually in GitHub settings)

---

## Verification

- [ ] `.asc/config.json` no longer tracked by git (`git ls-files .asc/config.json` returns empty)
- [ ] `git log --all --full-history -- .asc/config.json` returns no commits (or history purged)
- [ ] `LICENSE` exists at root with correct copyright
- [ ] `README.md` renders correctly on GitHub (check badge, links)
- [ ] Issue templates appear when creating new issue on GitHub
- [ ] `CODE_OF_CONDUCT.md` references `hello@geni.kids`
- [ ] Repo is publicly accessible at `github.com/yagomp/geni`
24 changes: 12 additions & 12 deletions ios/Geni.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 79;
DEVELOPMENT_TEAM = ZBRDR5Q455;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -414,7 +414,7 @@
"@executable_path/Frameworks",
);
CODE_SIGN_ENTITLEMENTS = Geni/Geni.entitlements;
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.yagomp.geni;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand All @@ -435,7 +435,7 @@
CODE_SIGN_ENTITLEMENTS = Geni/Geni.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 79;
DEVELOPMENT_TEAM = ZBRDR5Q455;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -451,7 +451,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.yagomp.geni;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Geni AppStore";
Expand All @@ -470,11 +470,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 79;
DEVELOPMENT_TEAM = ZBRDR5Q455;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.tests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
Expand All @@ -492,11 +492,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 79;
DEVELOPMENT_TEAM = ZBRDR5Q455;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.tests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
Expand All @@ -513,10 +513,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 79;
DEVELOPMENT_TEAM = ZBRDR5Q455;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.uitests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
Expand All @@ -533,10 +533,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 79;
DEVELOPMENT_TEAM = ZBRDR5Q455;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.uitests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
Expand Down
31 changes: 30 additions & 1 deletion ios/Geni/Models/ChapterProgress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ nonisolated struct ChapterProgress: Codable, Identifiable, Sendable {
let date: String
var chapterType: ChapterType
var status: ChapterStatus
var completedExerciseCount: Int
var exerciseResults: [ExerciseResult]
var stars: Int
var coinsEarned: Int
Expand All @@ -17,11 +18,39 @@ nonisolated struct ChapterProgress: Codable, Identifiable, Sendable {
self.date = date
self.chapterType = chapterType
self.status = .inProgress
self.completedExerciseCount = 0
self.exerciseResults = []
self.stars = 0
self.coinsEarned = 0
}

private enum CodingKeys: String, CodingKey {
case id
case childId
case date
case chapterType
case status
case completedExerciseCount
case exerciseResults
case stars
case coinsEarned
case completedAt
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
childId = try container.decode(String.self, forKey: .childId)
date = try container.decode(String.self, forKey: .date)
chapterType = try container.decodeIfPresent(ChapterType.self, forKey: .chapterType) ?? .daily
status = try container.decode(ChapterStatus.self, forKey: .status)
exerciseResults = try container.decodeIfPresent([ExerciseResult].self, forKey: .exerciseResults) ?? []
completedExerciseCount = try container.decodeIfPresent(Int.self, forKey: .completedExerciseCount) ?? exerciseResults.count
stars = try container.decodeIfPresent(Int.self, forKey: .stars) ?? 0
coinsEarned = try container.decodeIfPresent(Int.self, forKey: .coinsEarned) ?? 0
completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt)
}

var correctCount: Int {
exerciseResults.filter(\.wasCorrect).count
}
Expand All @@ -35,7 +64,7 @@ nonisolated struct ChapterProgress: Codable, Identifiable, Sendable {
}

var isComplete: Bool {
exerciseResults.count >= 20
completedExerciseCount >= 20
}

mutating func calculateRewards() {
Expand Down
64 changes: 44 additions & 20 deletions ios/Geni/Models/Exercise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,27 +146,14 @@ nonisolated struct Exercise: Identifiable, Sendable {
self.proposedAnswer = nil
self.comparisonLeft = (operand1, operation, operand2)

let rightAnswer = answer + Int.random(in: -3...3)
let safeRight = max(rightAnswer, 0)
let rOp2 = max(Int.random(in: 1...max(operand2, 2)), 1)
let rOp1: Int
switch operation {
case .addition: rOp1 = safeRight - rOp2
case .subtraction: rOp1 = safeRight + rOp2
case .multiplication: rOp1 = rOp2 > 0 ? safeRight / max(rOp2, 1) : safeRight
case .division: rOp1 = safeRight * max(rOp2, 1)
}
let safeROp1 = max(rOp1, 0)
self.comparisonRight = (safeROp1, operation, rOp2)

let leftVal = answer
let rightVal: Int
switch operation {
case .addition: rightVal = safeROp1 + rOp2
case .subtraction: rightVal = safeROp1 - rOp2
case .multiplication: rightVal = safeROp1 * rOp2
case .division: rightVal = rOp2 > 0 ? safeROp1 / rOp2 : 0
}
let rightVal = Self.distinctComparisonTarget(from: leftVal)
let rightOperands = Self.comparisonOperands(
for: rightVal,
operation: operation,
referenceOperand2: operand2
)
self.comparisonRight = (rightOperands.0, operation, rightOperands.1)

if leftVal >= rightVal {
self.options = [0, 1]
Expand Down Expand Up @@ -502,6 +489,43 @@ nonisolated struct Exercise: Identifiable, Sendable {
var trueFalseIsCorrect: Bool {
proposedAnswer == correctAnswer
}

private static func distinctComparisonTarget(from answer: Int) -> Int {
let lowerBound = max(0, answer - 3)
let upperBound = answer + 3
let candidates = Array(lowerBound...upperBound).filter { $0 != answer }
return candidates.randomElement() ?? (answer + 1)
}

private static func comparisonOperands(
for target: Int,
operation: MathOperation,
referenceOperand2: Int
) -> (Int, Int) {
switch operation {
case .addition:
let right = target > 0 ? Int.random(in: 0...target) : 0
return (target - right, right)

case .subtraction:
let right = Int.random(in: 0...max(referenceOperand2 + 3, 3))
return (target + right, right)

case .multiplication:
if target == 0 {
let factor = Int.random(in: 1...max(referenceOperand2, 2))
return (0, factor)
}

let divisors = (1...target).filter { target.isMultiple(of: $0) }
let right = divisors.randomElement() ?? 1
return (target / right, right)

case .division:
let divisor = Int.random(in: 1...max(referenceOperand2, 2))
return (target * divisor, divisor)
}
}
}

nonisolated enum ExerciseDifficulty: Int, Codable, Sendable {
Expand Down
Loading