diff --git a/.github/assets/ironlog-hero.svg b/.github/assets/ironlog-hero.svg index dfe407a..48174f7 100644 --- a/.github/assets/ironlog-hero.svg +++ b/.github/assets/ironlog-hero.svg @@ -1,49 +1,45 @@ - - IronLog — Train. Recover. Prove it. - An animated training instrument moves a forge-orange signal through a barbell surrounding the IronLog logo, a recovery ring, and a completed ledger mark. + + IronLog — Train. Recover. Prove it. + A workout set becomes recovery context and then an earned ledger entry. A signal moves through the three stages. - - - - LOCAL-FIRST ANDROID TRAINING - IRONLOG - Train. Recover. Prove it. - Fast logging, honest recovery, durable records. - - LOG - - RECOVER - - PROGRESS - - - - - - - - - - - - - - - - THE WORK IS THE RECORD + + + THE WORK IS THE RECORD + IRONLOG + Train. Recover. Prove it. + Log the set. Read the recovery. + Carry the evidence into the next session. + + + LOCAL-FIRST BY DESIGN + + A SINGLE TRAINING LOOP + + + + + + + + LOG THE SET + Load · reps · effort + + + READ RECOVERY + History · check-in · readiness + + + EARN THE RECORD + Credited work · durable progress diff --git a/.github/assets/screens/home.png b/.github/assets/screens/home.png index b6cf102..6855d19 100644 Binary files a/.github/assets/screens/home.png and b/.github/assets/screens/home.png differ diff --git a/.github/assets/training-loop.svg b/.github/assets/training-loop.svg index 1306c9c..640aa75 100644 --- a/.github/assets/training-loop.svg +++ b/.github/assets/training-loop.svg @@ -1,26 +1,45 @@ - - The IronLog training loop - A signal moves from workout logging to recovery, the Iron Ledger, and the next session. + + The IronLog training loop + Four connected steps: record a set, assess recovery, credit the completed work, and adjust the next session. - - One continuous training record - The next decision is informed by the work already done. - - - - LOG THE SETload · reps · effort - READ RECOVERYmuscles · sleep · readiness - EARN THE LEDGERstreaks · PRs · milestones - GO AGAINplan · adjust · progress + + One continuous training record + Each decision keeps the evidence from the session before it. + + + + + + + 01 / TRAIN + Record the set + Load, reps and effort + + + + 02 / RECOVER + Read the context + History and check-in + + + + 03 / PROVE + Credit the work + Streaks, PRs and Ledger + + + + 04 / REPEAT + Adjust the plan + A better next session + diff --git a/.gitignore b/.gitignore index 5ff0a35..7467f62 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ Thumbs.db .agents/ .impeccable/ artifacts/ +# Local CI artifact downloads and rendered previews +output/ PROJECT.md # Local Android diagnostics can contain device and account metadata bugreport-*.zip diff --git a/README.md b/README.md index 53bb61f..b220ff3 100644 --- a/README.md +++ b/README.md @@ -3,147 +3,104 @@ ![IronLog — Train. Recover. Prove it.](.github/assets/ironlog-hero.svg) [![Android CI](https://github.com/Yannam-Builds/Ironlog/actions/workflows/android.yml/badge.svg)](https://github.com/Yannam-Builds/Ironlog/actions/workflows/android.yml) +[![IronLog Web](https://github.com/Yannam-Builds/Ironlog/actions/workflows/web-pages.yml/badge.svg)](https://github.com/Yannam-Builds/Ironlog/actions/workflows/web-pages.yml) ![Android 8+](https://img.shields.io/badge/Android-8.0%2B-00C170?logo=android&logoColor=white) -![Kotlin](https://img.shields.io/badge/Kotlin-2.1-7F52FF?logo=kotlin&logoColor=white) ![Local first](https://img.shields.io/badge/data-local--first-FF4500) [![License](https://img.shields.io/badge/license-proprietary-B91C1C)](LICENSE) -**A native Android strength-training companion for fast logging, recovery awareness, plans, and an honest record of the work.** +**A strength-training companion that keeps the workout, recovery context, and earned progress in one local record.** -[Website & web app](#ironlog-web-preview) · [Features](#built-for-the-work-between-sets) · [Screens](#the-current-build) · [Architecture](#how-it-is-built) · [Authorized build](#build-for-authorized-evaluation) · [License](#license-and-ownership) +[Open the web app](https://yannam-builds.github.io/Ironlog/app/) · [Explore the website](https://yannam-builds.github.io/Ironlog/) · [See the native screens](#inside-the-android-app) · [Build locally](#build-and-verify) > [!NOTE] -> IronLog is under active development and remains a pre-alpha product. The current source identifies as `0.1.0-pre-alpha.8` (`versionCode 9`). There is currently no public APK release; build artifacts are not a substitute for a reviewed Play Store release. +> IronLog is in active pre-alpha development. The Android source defaults to `0.1.0-pre-alpha.8` (`versionCode 9`). There is no public APK release or Play Store listing yet. -## IronLog Web preview +## The training loop -The website now has a separate product landing page and a local-first browser app under [`web/`](web/README.md). The production addresses are [the website](https://yannam-builds.github.io/Ironlog/) and [the web app](https://yannam-builds.github.io/Ironlog/app/); GitHub Pages deploys the new output only after the website workflow passes. +![The four stages of the IronLog training loop](.github/assets/training-loop.svg) -The 2 September 2026 repository update includes: +Log load, reps and effort during a session. Use the session and a recovery check-in to understand the next decision. Credit completed work to the Iron Ledger, then adjust the plan. The motion in these diagrams follows that flow and stops when reduced motion is requested. -- The current transparent monochrome IronLog mark, lighter Lexend landing typography, Forge Fox artwork and twelve native themes shared between the page and app. Native app headings keep their heavier weight. -- **Make it yours:** Lexend plus 20 locally bundled font families, Original/Light/Regular/Bold styles, and a Settings → Layout spacing slider (85–125%). Font selection is shared by the landing page, phone preview and full-screen web app. Spacing adjusts padding and gaps without zooming text or artwork. Choose **Lexend + Light** for the landing-page feel; defaults remain unchanged until you choose. -- A real interactive app inside a phone frame on the landing page—not a desktop dashboard or an Android emulator. Onboarding and workouts save in this browser. Open the same app full-screen for Android Chrome or iPhone Safari; no separate account is needed. -- Home, Plans, Log, Stats and Settings; saved onboarding, editable plans, custom exercises, notes, explicit warmup queues, resumable workouts, set editing/deletion, rest timers and plate calculation. -- Local history, recovery estimates, Ledger progression, body measurements, photo comparison and validated backup/restore. No account, phone synchronization or browser cloud-AI backend. -- Fifteen distinct transparent achievement emblems, a provisional onboarding baseline that reflects entered training history without double-crediting older proof, and a zero-reward sensible-default path. -- Opaque modal sheets with keyboard focus wrapping, Escape dismissal and blocked background taps; mobile safe-area layouts and accessible numeric entry. -- A six-paper research bibliography with limitations, separate software/font notices, and optional Home Screen installation. +## Use IronLog -**Preview, not full native parity.** The web acceptance suite covers unit, browser, production-output, responsive, appearance, persistence and offline-startup paths. These automated results do not replace physical iPhone Home Screen, VoiceOver or broad Android-device verification. See the [acceptance record](web/docs/acceptance.md), [native parity gaps](web/docs/domain-parity.md) and [offline investigation](web/diagnostics/OFFLINE-WEBKIT.md). +| Android app | Web app | +| --- | --- | +| Jetpack Compose app with ObjectBox storage, Health Connect, foreground workout tracking, widgets, and optional on-device or user-configured cloud intelligence. | Installable browser app with local workout storage, a full-screen mobile layout, and a live app preview inside the website. | +| Plans, workout logging, history, recovery, body tracking, Ledger, and explicit backup/restore. | Home, Plans, Log, Stats, Settings, recovery, measurements, photo comparison, Ledger, and validated backup/restore. | -**Live deployment verified, 31 August 2026:** [workflow run for `974ed50`](https://github.com/Yannam-Builds/Ironlog/actions/runs/33373046339) passed 85 unit tests, 29 browser tests, both supplemental offline tests, build/output checks and deployment. Pages now uses **GitHub Actions** and serves only the built `web/dist`, not the legacy README renderer. All nine phone-preview tests also passed against the public website, including embedded workout completion in Chromium, WebKit and Android Chrome emulation. The app-independent WebKit offline-emulation failure remains a diagnostic; the application gate closes its actual HTTP origin and verifies cached startup and workout persistence without it. +The web app shares IronLog's visual system and core training flow. It is still a preview: Android-only integrations and some native behavior do not have browser equivalents. The [web acceptance record](web/docs/acceptance.md) and [parity notes](web/docs/domain-parity.md) describe what is tested and what remains different. -Workout instruction media from OpenGym is **not bundled**: its media notice and upstream dataset require separate reuse permission. Kotlin source, APKs and phone data are unchanged by this website update. +Both versions keep their primary record on the device or browser where it was created. There is no automatic account sync. Export and restore are explicit actions; keep a backup before clearing browser data or changing devices. -The [deeper OpenGym feature review](web/docs/opengym-feature-reference-2026-08-31.md) proposes six independently implementable Kotlin improvements: source-linked records, consistent exercise discovery, explicit note lifetimes, date-specific scheduling, atomic day copying and clearly scoped backups. It includes source-level issues to investigate, not claims that those native changes have shipped. - -Run locally with Node 24: - -```sh -cd web -npm ci -npm run dev -``` - -The landing page runs at `http://127.0.0.1:5173/Ironlog/`. See the [web README](web/README.md) for production-preview and test commands. - -## Built for the work between sets - -IronLog keeps the main training loop fast and keeps the primary record on your device. - -- **Log without friction.** Active sessions support sets, load, reps, timers, exercise substitutions, progression suggestions, and resumable foreground tracking. -- **See recovery, not just history.** Muscle readiness, sleep and biometric inputs, manual check-ins, and Health Connect can inform what to train next. -- **Work from a plan.** Build programs, choose starter plans, preserve exercise notes and supersets, create custom exercises, swap for one session or the plan, and exchange plans through validated JSON import/export. -- **Keep an honest ledger.** Streaks, XP, personal records, milestones, and Forge Fox widgets are derived from completed work. -- **Own the data.** ObjectBox stores the core record locally. Explicit IronLog backup, restore, import, and export remain available; automatic Android backup is disabled for sensitive fitness and photo data. -- **Choose the intelligence.** On-device and user-configured cloud AI paths are optional. The workout logger does not require an AI provider. - -![Animated diagram of the IronLog training loop](.github/assets/training-loop.svg) - -## The current build +## Inside the Android app - - + + - - + +
IronLog home screen showing the next workout and recovery overviewIronLog recovery map with the front body view centered below the controlsIronLog Home with the sample athlete name Athlete, Daily Proof, and the next training actionIronLog recovery map showing the front body view below its controls
Training command centerMuscle recovery mapHome · the next useful actionRecovery · context by muscle
-The September 2026 stabilization pass adds stable set identities and serialized workout mutations; pending warmups that never auto-log; durable exercise notes; transactional imports, restores, completion and notification cleanup; unified recovery, progression and Ledger inputs; compact effort controls; opaque overlays; configurable typography, spacing, card shine and liquid-glass navigation; normalized fine-grained muscle contributions for the recovery map; a blob-free responsive onboarding composition; and expanded regression coverage. QR plan sharing has been removed in favor of JSON because complete plans are too large for reliable QR transport. +The Home image is a native sample capture using the name **Athlete**. It is not a mockup or a screenshot of a personal training account. + +### Built for the work between sets + +- **Log quickly.** Record sets, load, reps, effort, rest and notes; resume an active workout after leaving the screen. +- **Follow a plan.** Use a starter program or edit days and exercises. Progression suggestions and substitutions remain explainable and optional. +- **Read recovery with context.** Workout history, manual check-ins, sleep and supported Health Connect inputs can inform training decisions. Readiness is an estimate, not medical clearance. +- **Keep honest progress.** Personal records, streaks, XP, badges and Forge Fox widgets depend on completed training evidence. +- **Own the record.** Workout data lives locally in ObjectBox on Android. IronLog backup, import and export are explicit; automatic Android backup is disabled for sensitive fitness and photo data. ## How it is built ```mermaid -flowchart TD - UI["Jetpack Compose UI"] --> VM["ViewModels and UI state"] - VM --> DOMAIN["Training, recovery, and ledger engines"] - DOMAIN --> REPOS["Repositories"] - REPOS --> DB[(ObjectBox)] - REPOS --> HC["Health Connect"] - DOMAIN --> AI["Optional local or cloud AI"] - REPOS --> PORT["Backup, import, and export"] - DB --> WIDGETS["Glance widgets and workers"] +flowchart LR + UI["Compose screens"] --> STATE["ViewModels"] + STATE --> DOMAIN["Training · recovery · Ledger"] + DOMAIN --> DATA["Repositories"] + DATA --> DB[("ObjectBox")] + DATA --> PORT["Backup / import / export"] + DATA --> HC["Health Connect"] + DOMAIN --> AI["Optional intelligence"] + DB --> WIDGETS["Widgets and workers"] ``` -The app is Kotlin-first and uses Jetpack Compose, ObjectBox, WorkManager, Health Connect, CameraX/ML Kit, Jetpack Glance, Vico, Coil, and Ktor. Java 17 is required for the Android build. +The Android app uses Kotlin, Jetpack Compose, ObjectBox, WorkManager, Health Connect, CameraX/ML Kit and Jetpack Glance. The separate [web project](web/README.md) uses React, TypeScript and IndexedDB. -## Build for authorized evaluation +## Build and verify -The source is publicly visible for product evaluation and project transparency, but it is not open source. Building, modifying, deploying or redistributing it requires prior written permission under the [IronLog Proprietary License](LICENSE). +The source is visible for evaluation, but is proprietary. Building, modifying, deploying or redistributing it requires prior written permission under the [IronLog Proprietary License](LICENSE). -Requirements: Android Studio with Android SDK 36, JDK 17, and an Android 8.0+ device or emulator. +Android requires JDK 17 and Android SDK 36. From the repository root: -```bash -git clone https://github.com/Yannam-Builds/Ironlog.git -cd Ironlog +```sh ./gradlew testDebugUnitTest lintDebug assembleDebug ``` -On Windows, use `gradlew.bat` instead of `./gradlew`. +Use `gradlew.bat` on Windows. Release signing stays local; do not commit `local.properties` or a keystore. See [Contributing](CONTRIBUTING.md) and [Security](SECURITY.md). -Release signing is intentionally local. Create `local.properties` with your own version and signing values and provide your own `app/ironlog-release.jks`; never reuse project-owner signing material. +The web project requires Node 24: -```properties -version.code=1 -version.name=1.0.0 -signing.storePassword=your-store-password -signing.keyPassword=your-key-password +```sh +cd web +npm ci +npm test +npm run build +npm run verify:output ``` -## Verification status - -- `:app:lintDebug` passes with no errors. -- The current JVM suite reports **755 tests**, zero failures/errors and one intentional skip. -- The latest API 36.1 emulator instrumentation pass reports **66 tests**, zero failures/errors and one intentional skip. -- The web unit suite reports **104 tests**, zero failures, and the TypeScript and production/PWA builds pass. -- Signed, minified APK and AAB builds pass with the private local signing configuration; APK signature and release-content checks pass, and a fresh release install opens onboarding without QA fixture data. -- Fresh onboarding, active-workout persistence, notes, warmups, recovery, Ledger, notifications, responsive layouts and release-upgrade behavior have dedicated automated or exploratory coverage. -- A signed release was installed over the existing physical-device build with app data preserved; wider OEM/API coverage is still required before a Play Store production claim. - -Physical-device coverage is still being expanded. See [Security](SECURITY.md) for private vulnerability reporting and [Contributing](CONTRIBUTING.md) before proposing a change. +GitHub Actions also runs browser acceptance in Chromium and WebKit, including a cached workout with the HTTP origin stopped. See the [Android workflow](https://github.com/Yannam-Builds/Ironlog/actions/workflows/android.yml) and [web workflow](https://github.com/Yannam-Builds/Ironlog/actions/workflows/web-pages.yml) for the current result. Automated coverage does not replace physical-device and accessibility review. ## Project status -Current release blockers are broader API 26–35 and physical-device regression testing, Play data-safety/Health Connect declarations and review of the [public privacy notice](https://yannam-builds.github.io/Ironlog/privacy/), cloud-AI reporting/disable controls where Play policy requires them, signing-key rotation plus historic-secret remediation, foreground-service declaration review, and a production version/release decision. The dated [app audit](docs/reviews/2026-08-31-ironlog-app-audit.md) and [notification/feature harmony audit](docs/reviews/2026-09-01-notification-feature-harmony-settings-audit.md) separate current work from historical implementation notes. +Before a store release, IronLog still needs broader Android API/OEM regression coverage, Play data-safety and Health Connect declarations, final privacy and foreground-service review, signing-key/history remediation, and a release version decision. The [native app audit](docs/reviews/2026-08-31-ironlog-app-audit.md) and [notification/settings audit](docs/reviews/2026-09-01-notification-feature-harmony-settings-audit.md) record known issues and their evidence. -## License and ownership +Original IronLog code, branding and artwork are proprietary. Earlier versions remain under the terms shipped with those versions; public visibility does not make this repository open source. Third-party software, font and data notices are in [Third-party notices](THIRD_PARTY_NOTICES.md). -IronLog's current and future original code, product design, branding and artwork are proprietary. No permission is granted to sell, redistribute, rebrand, host, monetize, or create derivative products without a separate written agreement from the owner. Public visibility does not make this an open-source project. - -Versions previously published under other terms remain governed by the terms that accompanied those versions; in particular, rights already granted under an earlier MIT release cannot be revoked retroactively. GitHub also gives users limited platform rights to view and fork public repositories. Keeping the source private while publishing only official builds is the stronger option if public inspection is no longer desired. - -Required attributions and licenses for third-party fonts, software and exercise data are preserved in [Third-party notices](THIRD_PARTY_NOTICES.md). Authorized contributors must read the [contribution policy](CONTRIBUTING.md) before submitting code or assets. For commercial licensing or acquisition discussions, contact `ironlogsupport@gmail.com`. - ---- - -
- Train. Recover. Prove it.
- Built for the record you earn. -
+
Train. Recover. Prove it.
diff --git a/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt index 5c452a4..f5cb188 100644 --- a/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt @@ -114,6 +114,8 @@ import com.ironlog.app.data.repository.SettingsRepository import com.ironlog.app.data.repository.ProgressionPolicySnapshot import com.ironlog.app.data.repository.ProgressionPolicyStore import com.ironlog.app.domain.gamification.DailyProofStatus +import com.ironlog.app.domain.gamification.dailyWorkoutStreakDays +import com.ironlog.app.domain.gamification.parseHistoryLocalDate import com.ironlog.app.domain.gamification.IronGrade import com.ironlog.app.domain.badges.BadgeDefinitions import com.ironlog.app.domain.intelligence.ManualRecoveryInput @@ -689,10 +691,8 @@ private fun streakContextSubtitle(streak: Int, lastWorkoutDate: String?): String if (streak >= 14) return "🔥 $streak day streak — unstoppable!" if (streak >= 7) return "🔥 $streak day streak — keep it going!" if (streak >= 3) return "💪 $streak days strong — don't stop now!" - val iso = lastWorkoutDate?.substringBefore('T') ?: return timeGreeting() - val days = runCatching { - ChronoUnit.DAYS.between(LocalDate.parse(iso), LocalDate.now()).toInt() - }.getOrDefault(-1) + val lastDate = lastWorkoutDate?.let { parseHistoryLocalDate(it) } ?: return timeGreeting() + val days = ChronoUnit.DAYS.between(lastDate, LocalDate.now()).toInt() return when (days) { 0 -> "You trained today — amazing!" 1 -> "Pick up where you left off" @@ -1335,36 +1335,26 @@ private fun homeScreenAgeDays(iso: String): Long = runCatching { ChronoUnit.DAYS.between(date, LocalDate.now()) }.getOrDefault(Long.MAX_VALUE) -fun getStreak(history: List): Int { - if (history.isEmpty()) return 0 - val days = history.map { it.date.substringBefore('T') }.toSet().sortedDescending() - val today = LocalDate.now().toString() - val yesterday = LocalDate.now().minusDays(1).toString() - if (days.first() != today && days.first() != yesterday) return 0 - var streak = 0 - var prev = LocalDate.parse(days.first()) - for (d in days) { - val cur = LocalDate.parse(d) - val diff = java.time.Duration.between(cur.atStartOfDay(), prev.atStartOfDay()).toDays() - if (diff <= 1) { streak++; prev = cur } else break - } - return streak -} +fun getStreak( + history: List, + nowEpochMs: Long = System.currentTimeMillis(), + zoneId: ZoneId = ZoneId.systemDefault(), +): Int = dailyWorkoutStreakDays(history, nowEpochMs, zoneId) -fun getWeekKey(dateString: String): String { - val date = LocalDate.parse(dateString.substringBefore('T')) +fun getWeekKey(dateString: String, zoneId: ZoneId = ZoneId.systemDefault()): String { + val date = parseHistoryLocalDate(dateString, zoneId) ?: return "" val fields = WeekFields.ISO return "${date.get(fields.weekBasedYear())}-W${date.get(fields.weekOfWeekBasedYear()).toString().padStart(2, '0')}" } -fun getGoalStreak(history: List, weeklyGoalDays: Int): Int { +fun getGoalStreak(history: List, weeklyGoalDays: Int, zoneId: ZoneId = ZoneId.systemDefault()): Int { val target = max(1, min(7, weeklyGoalDays)) if (history.isEmpty()) return 0 val byWeek = linkedMapOf>() history.forEach { session -> - val weekKey = getWeekKey(session.date) - val dayKey = session.date.substringBefore('T') - byWeek.getOrPut(weekKey) { linkedSetOf() }.add(dayKey) + val day = parseHistoryLocalDate(session.date, zoneId) ?: return@forEach + val weekKey = getWeekKey(session.date, zoneId) + byWeek.getOrPut(weekKey) { linkedSetOf() }.add(day.toString()) } var streak = 0 byWeek.keys.sortedDescending().forEach { week -> @@ -1374,14 +1364,16 @@ fun getGoalStreak(history: List, weeklyGoalDays: Int): Int { return streak } -fun countSessionsThisWeek(history: List): Int { - val now = LocalDate.now() +fun countSessionsThisWeek( + history: List, + now: LocalDate = LocalDate.now(), + zoneId: ZoneId = ZoneId.systemDefault(), +): Int { val fields = WeekFields.ISO val y = now.get(fields.weekBasedYear()) val w = now.get(fields.weekOfWeekBasedYear()) - return history.mapNotNull { - runCatching { LocalDate.parse(it.date.substringBefore('T')) }.getOrNull() - }.count { d -> d.get(fields.weekBasedYear()) == y && d.get(fields.weekOfWeekBasedYear()) == w } + return history.mapNotNull { parseHistoryLocalDate(it.date, zoneId) } + .count { d -> d.get(fields.weekBasedYear()) == y && d.get(fields.weekOfWeekBasedYear()) == w } } @Composable diff --git a/app/src/main/java/com/ironlog/app/ui/screens/stats/HistoryCalendarProjection.kt b/app/src/main/java/com/ironlog/app/ui/screens/stats/HistoryCalendarProjection.kt new file mode 100644 index 0000000..d57b191 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/stats/HistoryCalendarProjection.kt @@ -0,0 +1,14 @@ +package com.ironlog.app.ui.screens.stats + +import com.ironlog.app.domain.gamification.parseHistoryLocalDate +import java.time.LocalDate +import java.time.ZoneId + +/** Group recorded instants by the athlete's calendar day, retaining date-only imports. */ +internal fun countSessionsByLocalDate( + timestamps: List, + zoneId: ZoneId = ZoneId.systemDefault(), +): Map = + timestamps.mapNotNull { parseHistoryLocalDate(it, zoneId) } + .groupingBy { it } + .eachCount() diff --git a/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt index 1f722c9..165b6cd 100644 --- a/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt +++ b/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt @@ -164,33 +164,32 @@ fun StatsScreen( ) var chartRange by remember { mutableStateOf("14D") } val rangedChartPoints = remember(state.history, chartRange) { - val datestamps = state.history.map { it.date.substringBefore('T') } - val counts = datestamps.groupingBy { it }.eachCount() + val counts = countSessionsByLocalDate(state.history.map { it.date }) val formatter = java.time.format.DateTimeFormatter.ofPattern("dd/MM") when (chartRange) { "30D" -> { val labelIndices = setOf(0, 9, 19, 29) (0 until 30).map { i -> val day = java.time.LocalDate.now().minusDays((29 - i).toLong()) - com.ironlog.app.ui.model.ChartPoint(value = counts[day.toString()] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") + com.ironlog.app.ui.model.ChartPoint(value = counts[day] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") } } "90D" -> { val labelIndices = setOf(0, 29, 59, 89) (0 until 90).map { i -> val day = java.time.LocalDate.now().minusDays((89 - i).toLong()) - com.ironlog.app.ui.model.ChartPoint(value = counts[day.toString()] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") + com.ironlog.app.ui.model.ChartPoint(value = counts[day] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") } } "All" -> { - if (datestamps.isEmpty()) emptyList() + if (counts.isEmpty()) emptyList() else { - val earliest = datestamps.minOrNull()?.let { java.time.LocalDate.parse(it) } ?: java.time.LocalDate.now() + val earliest = counts.keys.minOrNull() ?: java.time.LocalDate.now() val totalDays = java.time.temporal.ChronoUnit.DAYS.between(earliest, java.time.LocalDate.now()).toInt() + 1 val step = maxOf(1, totalDays / 30) (0 until totalDays step step).map { i -> val day = earliest.plusDays(i.toLong()) - val windowCount = (0 until step).sumOf { offset -> counts[earliest.plusDays((i + offset).toLong()).toString()] ?: 0 } + val windowCount = (0 until step).sumOf { offset -> counts[earliest.plusDays((i + offset).toLong())] ?: 0 } com.ironlog.app.ui.model.ChartPoint(value = windowCount, label = if (i == 0 || i >= totalDays - step) day.format(formatter) else "") } } diff --git a/app/src/test/java/com/ironlog/app/ui/screens/home/HomeDateProjectionTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/home/HomeDateProjectionTest.kt new file mode 100644 index 0000000..8b30c87 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/home/HomeDateProjectionTest.kt @@ -0,0 +1,35 @@ +package com.ironlog.app.ui.screens.home + +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class HomeDateProjectionTest { + private val zone = ZoneId.of("Asia/Kolkata") + private val now = Instant.parse("2026-08-31T12:00:00Z").toEpochMilli() + + @Test fun `home streak and weekly count follow the athlete's local dates`() { + val history = listOf( + credited("today", "2026-08-30T18:45:00Z"), + credited("yesterday", "2026-08-29T19:00:00Z"), + HistoryEntry("invalid", "bad-date"), + ) + assertEquals(2, getStreak(history, now, zone)) + assertEquals(1, countSessionsThisWeek(history, LocalDate.of(2026, 8, 31), zone)) + assertEquals(getWeekKey("2026-08-31", zone), getWeekKey(history.first().date, zone)) + } + + private fun credited(id: String, date: String) = HistoryEntry( + id = id, + date = date, + duration = 45 * 60, + exercises = listOf(HistoryExercise(id = "$id-ex", name = "Bench Press", sets = List(8) { + HistoryExerciseSet(weight = 60.0, reps = 8.0) + })), + ) +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/stats/HistoryCalendarProjectionTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/stats/HistoryCalendarProjectionTest.kt new file mode 100644 index 0000000..5f5f8de --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/stats/HistoryCalendarProjectionTest.kt @@ -0,0 +1,24 @@ +package com.ironlog.app.ui.screens.stats + +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryCalendarProjectionTest { + @Test fun `session counts use local calendar day across UTC midnight`() { + val counts = countSessionsByLocalDate( + listOf("2026-08-30T18:45:00Z", "2026-08-31", "not-a-date"), + ZoneId.of("Asia/Kolkata"), + ) + assertEquals(mapOf(LocalDate.of(2026, 8, 31) to 2), counts) + } + + @Test fun `westward offsets keep early UTC sessions on the previous day`() { + val counts = countSessionsByLocalDate( + listOf("2026-09-01T06:30:00Z"), + ZoneId.of("America/Los_Angeles"), + ) + assertEquals(mapOf(LocalDate.of(2026, 8, 31) to 1), counts) + } +} diff --git a/docs/reviews/2026-09-25-native-audit-and-workspace.md b/docs/reviews/2026-09-25-native-audit-and-workspace.md new file mode 100644 index 0000000..faf4df6 --- /dev/null +++ b/docs/reviews/2026-09-25-native-audit-and-workspace.md @@ -0,0 +1,32 @@ +# Native app audit and workspace record — 25 September 2026 + +## Scope + +This is a follow-up to the [31 August native audit](2026-08-31-ironlog-app-audit.md), not a claim that every device path has been certified. I inventoried the Android source and tests, reviewed the earlier highest-priority findings against current implementation, traced date handling across Home, statistics and the shared Ledger, and ran the complete local debug JVM suite, lint and assembly with JDK 21. I also ran the same checks in the separate, in-progress `UnifiedPort` checkout without modifying its existing work. + +The GitHub checkout has 251 main Kotlin source files and 179 Kotlin test files after this change. The `UnifiedPort` checkout has dozens of modified and untracked files implementing onboarding, workout controls, assets and tests. Those files were preserved. Its generated build outputs, local SDK configuration, release signing material, diagnostic archive and private artifacts were not copied into this repository or deleted. + +## Confirmed and corrected + +Home's legacy streak and weekly summaries, and the 30-day/90-day/all-time Stats chart, interpreted UTC timestamp dates as calendar days using string slicing or UTC dates. A late-evening UTC workout can belong to the athlete's next local day. Home now uses the existing qualified-proof streak calculation and the shared local-date parser; Stats groups sessions by the same local calendar day. Focused tests cover UTC day crossings in Asia/Kolkata and America/Los_Angeles, date-only imports and malformed dates. + +The README now uses a real native sample Home capture labelled **Athlete**. The two SVG diagrams describe the actual training loop and respect reduced-motion and light/dark browser settings. The README states that the web app is a preview and that records do not automatically sync across Android and web. + +## Earlier release blockers checked + +The current import path validates payload rows before replacement, requires a fresh database fingerprint, refuses replacement during an active workout and saves a checked recovery snapshot before clearing rows. Photo ownership checks and explicit pain restrictions are present in current source and tests. This narrows the earlier P1 findings; it does not prove every imported legacy payload or physical-device flow is safe. The older audit remains a record of what was found at its original commit, not the current open-issue list. + +The set update path now has explicit `rpeUpdate`/`rirUpdate` semantics for clearing stored effort values. Credited workout proof uses a shared training-set policy. These address two earlier consistency findings at the source level; device-level edit/restart coverage is still warranted. + +## Remaining investigation + +- ObjectBox emitted native warnings during otherwise passing JVM tests about read-only cursors/transactions being destroyed on non-owner threads. `observeQuery` owns and closes each subscription and query, and its lifetime tests pass, but the warnings need a dedicated source-level and runtime investigation before they can be dismissed as test teardown noise. +- The Kotlin compiler reports a small number of deprecated Compose clipboard and icon APIs, and several coroutine-test opt-in warnings. These are maintenance items, not observed runtime crashes. +- The `UnifiedPort` checkout is an active dirty workspace. Do not bulk-clean, reset or move it as a substitute for review; reconcile its native work by feature and test before publishing it. The GitHub checkout ignores local `output/` previews and CI downloads, and keeps generated build files, SDK paths, keystores and diagnostics out of version control. +- The app still needs physical-device regression and accessibility passes, Android API/OEM coverage, release signing/history remediation and store-policy review before a stable public Android release. + +## Verification + +- GitHub checkout: `:app:testDebugUnitTest :app:lintDebug :app:assembleDebug` on JDK 21 passed locally (758 tests, zero failures; lint and debug assembly successful). Focused local-date tests are included. +- In-progress `UnifiedPort`: the same three tasks passed locally on JDK 21. This verifies the current dirty tree compiles and its JVM suite passes; it is not a release APK or a physical-device test. +- No phone data was cleared, no local signing key was copied, and no native release was installed during this audit.