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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@

**A strength-training companion that keeps the workout, recovery context, and earned progress in one local record.**

[Open the web app](https://yannam-builds.github.io/Ironlog/app/) · [Download Android preview](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.9) · [See the native screens](#inside-the-android-app) · [Build locally](#build-and-verify)
[Open the web app](https://yannam-builds.github.io/Ironlog/app/) · [Download Android preview](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.10) · [See the native screens](#inside-the-android-app) · [Build locally](#build-and-verify)

</div>

> [!NOTE]
> IronLog is in active pre-alpha development. The signed [`0.1.0-pre-alpha.9` Android preview APK](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.9) is available; there is no Play Store listing yet.
> IronLog is in active pre-alpha development. The signed [`0.1.0-pre-alpha.10` Android preview APK](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.10) is available; there is no Play Store listing yet.

## The training loop

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ android {
minSdk = 26
targetSdk = 36
versionCode = providers.gradleProperty("IRONLOG_VERSION_CODE").orNull?.toIntOrNull()
?: localProps.getProperty("version.code", "10").toInt()
?: 11
versionName = providers.gradleProperty("IRONLOG_VERSION_NAME").orNull
?: localProps.getProperty("version.name", "0.1.0-pre-alpha.9")
?: "0.1.0-pre-alpha.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import com.ironlog.app.ui.theme.IronLogRadius
import com.ironlog.app.ui.theme.IronLogType
import com.ironlog.app.util.buildFilterChipOptions
import com.ironlog.app.util.matchesExerciseFilter
import com.ironlog.app.util.normalizeExerciseNameKey
import com.ironlog.app.util.exerciseTutorialLink
import com.ironlog.app.util.queryExerciseSearch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -340,7 +340,7 @@ fun ExerciseLibraryScreen(
}

items(filtered, key = { it.id }) { ex ->
val hasVideo = videoMap.containsKey(normalizeExerciseNameKey(ex.name))
val hasVideo = exerciseTutorialLink(videoMap, ex.name) != null
ExerciseRow(
exercise = ex,
isFavorite = favoriteIds.contains(ex.id),
Expand All @@ -351,7 +351,7 @@ fun ExerciseLibraryScreen(
onClick = { onExerciseClick?.invoke(ex) },
hasVideo = hasVideo,
onVideo = {
val link = videoMap[normalizeExerciseNameKey(ex.name)] ?: return@ExerciseRow
val link = exerciseTutorialLink(videoMap, ex.name) ?: return@ExerciseRow
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ironlog.app.util

/** Preserve exact normalized names, then support the compact keys in the bundled tutorial asset. */
fun exerciseTutorialLink(links: Map<String, String>, name: String): String? {
val key = normalizeExerciseNameKey(name)
return links[key] ?: links[key.replace("_", "")]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ironlog.app.util

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class ExerciseTutorialLinksTest {
@Test fun resolvesDisplayNamesAgainstCompactAssetKeys() {
val links = mapOf("abcrunchmachine" to "https://example.com/ab", "plank" to "https://example.com/plank")
assertEquals("https://example.com/ab", exerciseTutorialLink(links, " Ab Crunch Machine "))
assertEquals("https://example.com/ab", exerciseTutorialLink(links, "AbCrunchMachine"))
assertEquals("https://example.com/plank", exerciseTutorialLink(links, "PLANK"))
assertNull(exerciseTutorialLink(links, "Athlete custom exercise"))
}

@Test fun exactNormalizedKeyTakesPrecedence() {
val links = mapOf("ab_crunch_machine" to "https://example.com/exact", "abcrunchmachine" to "https://example.com/compact")
assertEquals("https://example.com/exact", exerciseTutorialLink(links, "Ab Crunch Machine"))
}
}
11 changes: 11 additions & 0 deletions docs/reviews/2026-10-08-library-tutorial-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 2026-10-08 — Library tutorial lookup and controls

The native library looked up names with underscores against a compact-name tutorial asset. Of 1,731 seeded exercise display names, only 16 matched. Kotlin and web now prefer an exact normalized key, then try its compact spelling. This resolves 852 exercises using the existing 873 URLs; absent exercises receive no invented links. The web export includes the asset source hash and rejects non-HTTP(S) URLs.

Web library changes include the native full-screen destination without bottom tabs, back header with filtered count, separate 18px Compose trend control, stacked favorite/progress/delete/video actions, and query-driven custom creation. The two-letter broad-prefix search discrepancy and case-insensitive imported difficulty options are corrected. The list still uses reachable 100-row batches rather than a native LazyColumn; fixed header/list scrolling and exact filter-sheet geometry remain follow-ups.

Release version defaults are now public in Gradle (code 11, 0.1.0-pre-alpha.10), with explicit Gradle property overrides retained. Private local.properties version fields no longer silently change release identity. Signing settings and app data are untouched.

Verification before publication: 54 web test files / 268 tests pass, TypeScript/production build and 185-file output check pass. Browser QA at 320px verifies native tutorial URL, no horizontal overflow, persistent favorite, fullscreen library, separate progress route and both Back transitions. All 764 Kotlin tests (180 files) pass. Debug lint has zero errors, 162 warnings and 19 hints. Signed minified release build succeeds; apksigner verifies v2 with one signer. APK SHA-256: 511161590b5085e9df0c88227e436c39da36ef792073c0e979e5a1dca52e0789. Isolated API 36.1 QA update from pre-alpha.9 to .10 succeeded with adb install -r, firstInstallTime retained at 2026-10-05 23:45:09, Athlete retained, and empty crash buffer. Before/after QA confirms Ab Crunch Machine had no VIDEO button in pre-alpha.9 and now has one in .10. Evidence: ignored output/android/tutorial-before.xml, tutorial-after.xml and tutorial-after.png. The existing profile remains Athlete; no physical phone was touched. Test output included ObjectBox cursor-cleanup diagnostics despite zero failures; this is separate from Android runtime crash verification and remains an audit candidate.

Broader parity remains open: custom editing and native duplicate/rename persistence audit; plan drag/progression/editor guard details; history calendar/edit controls; cloud AI/provider/gym/retry flows; training intelligence and statistics/chart drills; body/photo export details; onboarding migration/retry checks; differential animation, gesture and transition captures. Android-only APIs are excluded. No 100% parity claim.
30 changes: 27 additions & 3 deletions web/scripts/extract-native.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,12 @@ function extract(source) {
throw Error("Native theme contract changed");
output("src/generated/themes.json", themes);
const raw = JSON.parse(read(prefix + "assets/exerciseLibrary.json"));
const exercises = nativeExerciseCatalog(raw.exercises,
read(prefix + "java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt").toString());
const exercises = nativeExerciseCatalog(
raw.exercises,
read(
prefix + "java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt",
).toString(),
);
output("public/data/exercises.json", exercises);
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const lookup = new Map(
Expand All @@ -163,6 +167,24 @@ function extract(source) {
for (const e of d.exercises)
e.exerciseId = lookup.get(norm(e.name)) ?? "";
output("src/generated/templates.json", templates);
const tutorials = JSON.parse(
read(prefix + "assets/ironlog/exercise_youtube_by_normalized_name.json"),
);
output(
"src/generated/exercise-tutorials.json",
Object.fromEntries(
Object.entries(tutorials).flatMap(([key, row]) => {
try {
const url = new URL(row.youtubeLink);
return ["http:", "https:"].includes(url.protocol)
? [[key, row.youtubeLink]]
: [];
} catch {
return [];
}
}),
),
);
output(
"src/generated/body-map.json",
JSON.parse(read(prefix + "assets/ironlog/body_map_paths.json")),
Expand All @@ -171,7 +193,9 @@ function extract(source) {
// by Compose. Export the complete set so web screens can select the same
// mascot pose, badge and grade art instead of maintaining approximations.
const art = prefix + "res/drawable-nodpi/";
const artwork = readdirSync(resolve(source, art)).filter((n) => n.endsWith(".png"));
const artwork = readdirSync(resolve(source, art)).filter((n) =>
n.endsWith(".png"),
);
const fonts = readdirSync(resolve(source, prefix + "res/font/")).filter((n) =>
n.endsWith(".ttf"),
);
Expand Down
12 changes: 6 additions & 6 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,21 +320,21 @@ export function App() {
<Onboarding />
) : (
<>
<div className="app-shell">
{detail && (
<div className={`app-shell${route === "library" ? " library-shell" : ""}`}>
{detail && route !== "library" && (
<header className="app-header detail-header">
<IconButton
name="back"
label="Back"
onClick={() => backFrom(route)}
/>
{route === "library" ? <h1 className="detail-screen-title">Exercise library</h1> : <strong>
<strong>
{route.startsWith("plan/")
? "EDIT PLAN"
: route.startsWith("history/")
? "WORKOUT"
: detailTitles[route] ?? "IRONLOG"}
</strong>}
</strong>
<span aria-hidden="true" />
</header>
)}
Expand Down Expand Up @@ -416,7 +416,7 @@ export function App() {
</div>
</main>
</div>
<nav className={`bottom-nav${data.profile.liquidGlassEnabled ? " liquid-glass" : ""}`} aria-label="Main">
{route !== "library" && <nav className={`bottom-nav${data.profile.liquidGlassEnabled ? " liquid-glass" : ""}`} aria-label="Main">
<span
className="bottom-nav-selection"
aria-hidden="true"
Expand All @@ -435,7 +435,7 @@ export function App() {
</a>
);
})}
</nav>
</nav>}
</>
)}
{busy && (
Expand Down
2 changes: 1 addition & 1 deletion web/src/domain/exercise-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function queryExerciseSearch(exercises: Exercise[], query: string): Exerc
exercise.equipment, exercise.category ?? "", exercise.movementPattern ?? "", exercise.difficulty ?? ""].join(" "));
const score = name === q ? 0 : name.startsWith(q) ? 1 : name.includes(q) || aliases.includes(q) ? 2
: words.length && words.every(word => name.includes(word)) ? 3
: words.some(word => name.split(" ").some(part => part.startsWith(word))) ? 4
: words.some(word => word.length >= 3 && name.split(" ").some(part => part.startsWith(word))) ? 4
: words.length && words.every(word => haystack.includes(word)) ? 5
: words.some(word => word.length >= 3 && haystack.includes(word)) ? 6 : 1000;
return { exercise, score };
Expand Down
13 changes: 13 additions & 0 deletions web/src/domain/exercise-tutorial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import links from "../generated/exercise-tutorials.json";
/** Kotlin exerciseTutorialLink: exact name key first, then bundled compact-name keys. */
export function exerciseTutorial(name: string): string | undefined {
const key = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/_+$/, "");
const lookup = Object.hasOwn(links, key) ? key : key.replace(/_/g, "");
return Object.hasOwn(links, lookup)
? (links as Record<string, string>)[lookup]
: undefined;
}
57 changes: 50 additions & 7 deletions web/src/features/ExerciseLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useApp, navigate } from "../ui/context";
import { Button, Icon, IconButton, Sheet } from "../ui/components";
import { deleteCustomExercise, setExerciseFavorite } from "../data/store";
import { queryExerciseSearch } from "../domain/exercise-search";
import { exerciseTutorial } from "../domain/exercise-tutorial";
import { backFrom } from "../ui/navigation";
import type { Exercise } from "../domain/types";
import { ExercisePicker } from "./Plans";

Expand Down Expand Up @@ -93,7 +95,10 @@ export function ExerciseLibrary() {
category: values("category"),
movementPattern: values("movementPattern"),
difficulty: ["beginner", "intermediate", "advanced", "expert"].filter(
(value) => values("difficulty").includes(value),
(value) =>
values("difficulty").some(
(difficulty) => difficulty.toLowerCase() === value,
),
),
};
}, [data.exercises]);
Expand Down Expand Up @@ -163,6 +168,21 @@ export function ExerciseLibrary() {

return (
<section className="exercise-library">
<header className="app-header detail-header library-header">
<IconButton
name="arrow-back"
size={24}
label="Back"
onClick={() => backFrom("library")}
/>
<div>
<h1 className="detail-screen-title">Exercise library</h1>
<small className="muted">
{list.length} exercise{list.length === 1 ? "" : "s"}
</small>
</div>
<span aria-hidden="true" />
</header>
<div className="library-toolbar">
<div className="library-active-filters">
{activeFilters.map(([key, value]) => {
Expand Down Expand Up @@ -226,10 +246,6 @@ export function ExerciseLibrary() {
<p className="muted" aria-live="polite">
{list.length} exercise{list.length === 1 ? "" : "s"}
</p>
<Button variant="ghost" onClick={() => setCreating(true)}>
<Icon name="plus" size={18} />
Create custom exercise
</Button>
</div>
{!!recent.length && !debounced.trim() && !activeFilters.length && (
<div className="library-recent">
Expand Down Expand Up @@ -268,6 +284,7 @@ export function ExerciseLibrary() {
<div className="library-row-actions">
<IconButton
name={favorites.has(ex.id) ? "star-filled" : "star"}
size={20}
label={`${favorites.has(ex.id) ? "Unfavorite" : "Favorite"} ${ex.name}`}
aria-pressed={favorites.has(ex.id)}
disabled={busy}
Expand All @@ -277,14 +294,34 @@ export function ExerciseLibrary() {
)
}
/>
<IconButton
name="trending-up"
size={18}
label={`Open progress for ${ex.name}`}
onClick={() =>
navigate(`exercise/${encodeURIComponent(ex.id)}`)
}
/>
{ex.custom && (
<IconButton
name="delete-outline"
size={24}
label={`Delete ${ex.name}`}
disabled={busy}
onClick={() => setDeleting(ex)}
/>
)}
{exerciseTutorial(ex.name) && (
<a
className="library-video"
href={exerciseTutorial(ex.name)}
target="_blank"
rel="noopener noreferrer"
aria-label={`Watch tutorial for ${ex.name}`}
>
▶ VIDEO
</a>
)}
</div>
</article>
))}
Expand Down Expand Up @@ -322,7 +359,10 @@ export function ExerciseLibrary() {
<button
key={scope}
className={`library-chip ${filters.scope === scope && (scope !== "all" || !filters.bodyweight) ? "active" : ""}`}
aria-pressed={filters.scope === scope && (scope !== "all" || !filters.bodyweight)}
aria-pressed={
filters.scope === scope &&
(scope !== "all" || !filters.bodyweight)
}
onClick={() => toggle("scope", scope)}
>
{title(scope)}
Expand All @@ -348,7 +388,10 @@ export function ExerciseLibrary() {
).map(
([key, label]) =>
!!options[key].length && (
<fieldset className="choice-fieldset library-filter-group" key={key}>
<fieldset
className="choice-fieldset library-filter-group"
key={key}
>
<legend>{label}</legend>
<div className="choice-chips">
{options[key].map((value) => (
Expand Down
Loading
Loading