initial commit

This commit is contained in:
parovin committed 2026-10-11 14:12:10 +03:00
commit 39fdd43d8e
127 files changed
+13919

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.md
+2
View File
@@ -0,0 +1,2 @@
# Offline V2: данные на устройстве (IndexedDB), сервер не нужен.
VITE_OFFLINE=true
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# Сборка Android APK (Capacitor) — Offline V2
APK работает **без сервера**: PIN, программа и прогресс хранятся на устройстве (IndexedDB).
## JDK для Gradle
Нужен **JDK 21** (не Java 25 из Android Studio).
Уже скачан в: `%USERPROFILE%\.jdks\temurin-21`
```powershell
$env:JAVA_HOME = "$env:USERPROFILE\.jdks\temurin-21"
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
```
## Сборка APK
```powershell
cd C:\Dev\Benchpress\frontend
npm run cap:sync
cd android
$env:JAVA_HOME = "$env:USERPROFILE\.jdks\temurin-21"
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
.\gradlew.bat assembleDebug
```
APK:
`C:\Dev\Benchpress\frontend\android\app\build\outputs\apk\debug\app-debug.apk`
`.env.android` содержит `VITE_OFFLINE=true` — сервер и `VITE_API_BASE_URL` не нужны.
## Online-режим (опционально)
Уберите `VITE_OFFLINE` / поставьте `false` и задайте `VITE_API_BASE_URL`, затем снова `cap:sync` + сборка. Для этого нужен запущенный backend (`docker compose up -d`).
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "com.benchpress.secret"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.benchpress.secret"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,15 @@
package com.benchpress.secret;
import android.os.Bundle;
import androidx.core.view.WindowCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Benchpress Secret</string>
<string name="title_activity_main">Benchpress Secret</string>
<string name="package_name">com.benchpress.secret</string>
<string name="custom_url_scheme">com.benchpress.secret</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:statusBarColor">#0f1412</item>
<item name="android:navigationBarColor">#0c100e</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+54
View File
@@ -0,0 +1,54 @@
# Builds a debug APK with a JDK that Gradle 8.14 / AGP 8.13 can run.
# The Java on PATH is Java 8. Android Studio's bundled JBR is Java 25,
# which this Gradle version does not support. Temurin 21 is the right runtime.
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
function Get-JavaMajor([string]$JdkDir) {
$release = Join-Path $JdkDir "release"
if (-not (Test-Path $release)) { return 0 }
$line = Select-String -Path $release -Pattern 'JAVA_VERSION="(\d+)' | Select-Object -First 1
if (-not $line) { return 0 }
return [int]$line.Matches[0].Groups[1].Value
}
function Find-Jdk {
$candidates = @(
"$env:USERPROFILE\.jdks\temurin-21",
"$env:USERPROFILE\.jdks\temurin-17",
${env:JAVA_HOME}
) | Where-Object { $_ }
foreach ($dir in $candidates) {
$java = Join-Path $dir "bin\java.exe"
if (-not (Test-Path $java)) { continue }
$major = Get-JavaMajor $dir
if ($major -ge 17 -and $major -le 24) {
return $dir
}
}
return $null
}
$jdk = Find-Jdk
if (-not $jdk) {
Write-Error @"
No JDK 17-21 found.
Install Temurin 21, or point JAVA_HOME at one.
Android Studio's bundled JBR is Java 25; Gradle 8.14.3 cannot use it.
The Java on PATH is Java 8; AGP 8.13 cannot use it either.
"@
}
$env:JAVA_HOME = $jdk
$env:PATH = "$(Join-Path $jdk 'bin');$env:PATH"
Write-Host "JAVA_HOME=$env:JAVA_HOME"
& (Join-Path $PSScriptRoot "gradlew.bat") assembleDebug @args
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$apk = Join-Path $PSScriptRoot "app\build\outputs\apk\debug\app-debug.apk"
if (Test-Path $apk) {
Write-Host "APK: $apk"
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+15
View File
@@ -0,0 +1,15 @@
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.benchpress.secret",
appName: "Benchpress Secret",
webDir: "dist",
server: {
androidScheme: "https",
},
android: {
allowMixedContent: true,
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f1412" />
<title>Benchpress Secret</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2617
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:android": "tsc -b && vite build --mode android",
"lint": "oxlint",
"preview": "vite preview",
"cap:sync": "npm run build:android && npx cap sync android",
"cap:open": "npx cap open android"
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
"dexie": "^4.4.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+7
View File
@@ -0,0 +1,7 @@
# Собирает debug APK. JDK подставляет android\build-apk.ps1 (Temurin 21).
# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\build-apk.ps1
$ErrorActionPreference = "Stop"
$gradleScript = Join-Path $PSScriptRoot "..\android\build-apk.ps1"
& $gradleScript @args
exit $LASTEXITCODE
+65
View File
@@ -0,0 +1,65 @@
# Ставит собранный debug APK на эмулятор и открывает приложение.
# Если виртуалка выключена, запускает первый AVD и ждёт загрузки.
# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\install-emulator.ps1
$ErrorActionPreference = "Stop"
$sdk = Join-Path $env:LOCALAPPDATA "Android\Sdk"
$adb = Join-Path $sdk "platform-tools\adb.exe"
$emulator = Join-Path $sdk "emulator\emulator.exe"
$apk = Resolve-Path (Join-Path $PSScriptRoot "..\android\app\build\outputs\apk\debug\app-debug.apk") -ErrorAction SilentlyContinue
$package = "com.benchpress.secret"
if (-not (Test-Path $adb)) {
Write-Error "adb не найден: $adb"
}
if (-not $apk) {
Write-Error "APK ещё нет. Сначала запусти scripts\build-apk.ps1"
}
function Get-EmulatorSerial {
$lines = & $adb devices
foreach ($line in $lines) {
if ($line -match "^(emulator-\d+)\s+device\s*$") {
return $Matches[1]
}
}
return $null
}
$serial = Get-EmulatorSerial
if (-not $serial) {
if (-not (Test-Path $emulator)) {
Write-Error "Эмулятор не найден: $emulator"
}
$avds = @(& $emulator -list-avds | Where-Object { $_ -and $_.Trim() })
if ($avds.Count -eq 0) {
Write-Error "Нет виртуальных устройств. Создай его в Android Studio: Device Manager."
}
$avd = $avds[0].Trim()
Write-Host "Запуск эмулятора $avd ..."
Start-Process -FilePath $emulator -ArgumentList @("-avd", $avd)
& $adb wait-for-device
$deadline = (Get-Date).AddMinutes(4)
do {
Start-Sleep -Seconds 2
$serial = Get-EmulatorSerial
if (-not $serial) { continue }
$boot = (& $adb -s $serial shell getprop sys.boot_completed | Out-String).Trim()
if ($boot -eq "1") { break }
} while ((Get-Date) -lt $deadline)
$serial = Get-EmulatorSerial
if (-not $serial) {
Write-Error "Эмулятор не успел загрузиться."
}
}
Write-Host "Установка на $serial ..."
& $adb -s $serial install -r $apk
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& $adb -s $serial shell am force-stop $package
& $adb -s $serial shell am start -n "$package/.MainActivity"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Приложение открыто на $serial"
+12
View File
@@ -0,0 +1,12 @@
# После правок в Cursor: собирает веб-приложение и копирует его в Android-проект.
# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\update-app.ps1
$ErrorActionPreference = "Stop"
$frontend = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $frontend
Write-Host "Сборка веб-части и cap sync..."
npm run cap:sync
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Готово. Дальше: .\build-apk.ps1, затем .\install-emulator.ps1"
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth";
import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage";
import { OnboardingPage } from "./pages/OnboardingPage";
import { SettingsPage } from "./pages/SettingsPage";
import { WorkoutPage } from "./pages/WorkoutPage";
function Guard({ children }: { children: React.ReactNode }) {
const { loading, token, hasPassword, onboarded } = useAuth();
if (loading) return <div className="page"><p className="muted">Загрузка…</p></div>;
if (!hasPassword || !token) return <LoginPage />;
if (!onboarded) return <OnboardingPage />;
return <>{children}</>;
}
function AppRoutes() {
return (
<Routes>
<Route
path="/"
element={
<Guard>
<HomePage />
</Guard>
}
/>
<Route
path="/workout/:id"
element={
<Guard>
<WorkoutPage />
</Guard>
}
/>
<Route
path="/settings"
element={
<Guard>
<SettingsPage />
</Guard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<AuthProvider>
<div className="app-shell">
<AppRoutes />
</div>
</AuthProvider>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { localApi } from "./local/api";
import { ApiError, type Profile, type WorkoutDetail, type WorkoutSummary } from "./apiTypes";
export { ApiError };
export type { Exercise, Profile, WorkoutDetail, WorkoutSet, WorkoutSummary } from "./apiTypes";
const TOKEN_KEY = "bps_token";
const OFFLINE = import.meta.env.VITE_OFFLINE === "true";
/** Пустой = относительные /api (web/nginx). Для online APK: полный URL. Offline — не используется. */
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") || "";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers || {});
headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
let msg = res.statusText;
try {
const data = await res.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
} catch {
/* ignore */
}
throw new ApiError(res.status, msg);
}
if (res.status === 204) return undefined as T;
return res.json();
}
const remoteApi = {
status: () => request<{ has_password: boolean; onboarded: boolean; phase: string }>("/api/auth/status"),
setup: (password: string) =>
request<{ access_token: string; onboarded: boolean }>("/api/auth/setup", {
method: "POST",
body: JSON.stringify({ password }),
}),
login: (password: string) =>
request<{ access_token: string; onboarded: boolean }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
}),
profile: () => request<Profile>("/api/profile"),
onboarding: (body: { one_rm_kg: number; training_weekdays: number[]; start_date: string }) =>
request<Profile>("/api/onboarding", { method: "POST", body: JSON.stringify(body) }),
calendar: (year: number, month: number) =>
request<{ year: number; month: number; workouts: WorkoutSummary[]; profile: Profile }>(
`/api/calendar?year=${year}&month=${month}`,
),
upcoming: () => request<WorkoutSummary[]>("/api/workouts/upcoming"),
workout: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}`),
start: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}/start`, { method: "POST" }),
pause: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}/pause`, { method: "POST" }),
resume: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}/resume`, { method: "POST" }),
completeSet: (id: number, body: { actual_reps?: number; actual_weight_kg?: number } = {}) =>
request<WorkoutDetail>(`/api/workouts/${id}/complete-set`, {
method: "POST",
body: JSON.stringify(body),
}),
setWeight: (workoutId: number, exerciseId: number, weight_kg: number) =>
request<WorkoutDetail>(`/api/workouts/${workoutId}/exercises/${exerciseId}/weight`, {
method: "POST",
body: JSON.stringify({ weight_kg }),
}),
adjustSet: (
workoutId: number,
exerciseId: number,
setId: number,
body: { actual_weight_kg: number; actual_reps: number },
) =>
request<WorkoutDetail>(`/api/workouts/${workoutId}/exercises/${exerciseId}/sets/${setId}`, {
method: "POST",
body: JSON.stringify(body),
}),
moveWorkout: (id: number, scheduledDate: string) =>
request<WorkoutDetail>(`/api/workouts/${id}/move`, {
method: "POST",
body: JSON.stringify({ scheduled_date: scheduledDate }),
}),
updateTrainingDays: (training_weekdays: number[]) =>
request<Profile>("/api/training-days", {
method: "POST",
body: JSON.stringify({ training_weekdays }),
}),
prompt: (
workoutId: number,
exerciseId: number,
body: {
choice: "easy_yes" | "easy_no";
weight_kg?: number;
increase_kg?: number;
decrease_kg?: number;
},
) =>
request<WorkoutDetail>(`/api/workouts/${workoutId}/exercises/${exerciseId}/prompt`, {
method: "POST",
body: JSON.stringify(body),
}),
startRest: (rest_days: number) =>
request<Profile>("/api/rest/start", { method: "POST", body: JSON.stringify({ rest_days }) }),
probe: (body: { one_rm_kg: number; start_date: string; training_weekdays?: number[] }) =>
request<Profile>("/api/probe", { method: "POST", body: JSON.stringify(body) }),
export: () => request<{ version: number; exported_at: string; data: unknown }>("/api/export"),
import: (payload: { version: number; data: unknown }) =>
request<{ message: string }>("/api/import", { method: "POST", body: JSON.stringify(payload) }),
reset: () => request<{ message: string }>("/api/reset", { method: "POST" }),
};
export const api = OFFLINE ? localApi : remoteApi;
export const isOfflineMode = OFFLINE;
export const WEEKDAY_LABELS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
export const WEEKDAY_FULL = [
"Понедельник",
"Вторник",
"Среда",
"Четверг",
"Пятница",
"Суббота",
"Воскресенье",
];
export function formatKg(v: number | null | undefined): string {
if (v == null) return "—";
return Number.isInteger(v) ? `${v}` : `${v}`;
}
export function formatTimer(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
export function formatRestCountdown(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const mins = Math.floor((s % 3600) / 60);
const secs = s % 60;
const n10 = days % 10;
const n100 = days % 100;
const word = n10 === 1 && n100 !== 11 ? "день" : n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14) ? "дня" : "дней";
return `${String(days).padStart(2, "0")} ${word} ${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
export function formatDhms(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const mins = Math.floor((s % 3600) / 60);
const secs = s % 60;
return `${String(days).padStart(2, "0")}, ${String(hours).padStart(2, "0")}. ${String(mins).padStart(2, "0")}. ${String(secs).padStart(2, "0")}`;
}
export function formatDateRu(isoDate: string): string {
const d = new Date(isoDate + "T12:00:00");
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long" });
}
+75
View File
@@ -0,0 +1,75 @@
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export type Profile = {
one_rm_kg: number | null;
training_weekdays: number[] | null;
start_date: string | null;
onboarded: boolean;
phase: string;
rest_until: string | null;
probe_available_at: string | null;
cycle_number: number;
rest_countdown_seconds: number | null;
can_probe: boolean;
};
export type WorkoutSummary = {
id: number;
cycle_number: number;
week_number: number;
day_index: number;
scheduled_date: string;
status: string;
exercise_count: number;
};
export type WorkoutSet = {
id: number;
set_index: number;
target_percent: number | null;
target_reps: number | null;
target_weight_kg: number | null;
is_failure: boolean;
completed: boolean;
actual_reps: number | null;
actual_weight_kg: number | null;
};
export type Exercise = {
id: number;
order_index: number;
name: string;
kind: string;
prescribed_weight_kg: number | null;
weight_locked: boolean;
needs_weight_input: boolean;
pending_prompt: string | null;
weight_step_kg: number;
sets: WorkoutSet[];
};
export type WorkoutDetail = {
id: number;
cycle_number: number;
week_number: number;
day_index: number;
scheduled_date: string;
status: string;
started_at: string | null;
paused_at: string | null;
completed_at: string | null;
current_exercise_index: number;
current_set_index: number;
rest_started_at: string | null;
rest_elapsed_seconds: number;
can_start: boolean;
can_resume: boolean;
pause_expires_at: string | null;
exercises: Exercise[];
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+80
View File
@@ -0,0 +1,80 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api, getToken, setToken } from "./api";
type AuthState = {
loading: boolean;
hasPassword: boolean;
onboarded: boolean;
token: string | null;
refresh: () => Promise<void>;
login: (password: string) => Promise<void>;
setup: (password: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [hasPassword, setHasPassword] = useState(false);
const [onboarded, setOnboarded] = useState(false);
const [token, setTokenState] = useState<string | null>(getToken());
const refresh = async () => {
const status = await api.status();
setHasPassword(status.has_password);
setOnboarded(status.onboarded);
if (getToken()) {
try {
const profile = await api.profile();
setOnboarded(profile.onboarded);
} catch {
setToken(null);
setTokenState(null);
}
}
};
useEffect(() => {
refresh()
.catch(() => undefined)
.finally(() => setLoading(false));
}, []);
const value = useMemo<AuthState>(
() => ({
loading,
hasPassword,
onboarded,
token,
refresh,
async login(password: string) {
const res = await api.login(password);
setToken(res.access_token);
setTokenState(res.access_token);
setOnboarded(res.onboarded);
setHasPassword(true);
},
async setup(password: string) {
const res = await api.setup(password);
setToken(res.access_token);
setTokenState(res.access_token);
setOnboarded(res.onboarded);
setHasPassword(true);
},
logout() {
setToken(null);
setTokenState(null);
},
}),
[loading, hasPassword, onboarded, token],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("AuthProvider missing");
return ctx;
}
+80
View File
@@ -0,0 +1,80 @@
import { useMemo, useState } from "react";
import { WEEKDAY_LABELS, type WorkoutSummary } from "../api";
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate();
}
export function AppCalendar({
value,
workouts = [],
onPick,
}: {
value: string;
workouts?: WorkoutSummary[];
onPick: (iso: string) => void;
}) {
const initial = new Date(value + "T12:00:00");
const [year, setYear] = useState(initial.getFullYear());
const [month, setMonth] = useState(initial.getMonth() + 1);
const byDate = useMemo(() => {
const map = new Map<string, WorkoutSummary>();
for (const w of workouts) map.set(w.scheduled_date, w);
return map;
}, [workouts]);
const firstWeekday = (new Date(year, month - 1, 1).getDay() + 6) % 7;
const totalDays = daysInMonth(year, month);
const today = new Date();
const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const shift = (delta: number) => {
const d = new Date(year, month - 1 + delta, 1);
setYear(d.getFullYear());
setMonth(d.getMonth() + 1);
};
const statusClass = (s: string) => {
if (s === "completed") return "done";
if (s === "in_progress" || s === "paused") return "active";
if (s === "abandoned") return "missed";
return "planned";
};
return (
<div className="calendar move-calendar">
<div className="cal-nav">
<button type="button" className="btn ghost" onClick={() => shift(-1)} aria-label="Предыдущий месяц">
‹
</button>
<h2>{new Date(year, month - 1, 1).toLocaleString("ru-RU", { month: "long", year: "numeric" })}</h2>
<button type="button" className="btn ghost" onClick={() => shift(1)} aria-label="Следующий месяц">
›
</button>
</div>
<div className="cal-grid head">
{WEEKDAY_LABELS.map((d) => (
<div key={d}>{d}</div>
))}
</div>
<div className="cal-grid">
{Array.from({ length: firstWeekday }).map((_, i) => (
<div key={`e${i}`} className="cal-cell empty" />
))}
{Array.from({ length: totalDays }).map((_, i) => {
const day = i + 1;
const key = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const w = byDate.get(key);
return (
<button
key={key}
type="button"
className={`cal-cell ${key === todayIso ? "today" : ""} ${key === value ? "picked" : ""} ${w ? statusClass(w.status) : ""}`}
onClick={() => onPick(key)}
>
<span>{day}</span>
{w && <i className="dot" />}
</button>
);
})}
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
type WeightStepperProps = {
value: number;
step?: number;
min?: number;
onChange: (value: number) => void;
label?: string;
compact?: boolean;
action?: ReactNode;
};
export function WeightStepper({
value,
step = 0.5,
min = 0,
onChange,
label,
compact,
action,
}: WeightStepperProps) {
const bump = (delta: number) => {
const next = Math.round((value + delta) / step) * step;
onChange(Math.max(min, Math.round(next * 100) / 100));
};
return (
<div className={`weight-stepper ${compact ? "compact" : ""}`}>
{label && <span className="weight-stepper-label">{label}</span>}
<div className="weight-stepper-row">
<button type="button" className="btn rm-btn" onClick={() => bump(-step)} aria-label="Минус">
−
</button>
<div className="weight-stepper-value">
<span className="num">{Number.isInteger(value) ? value : value.toFixed(1)}</span>
<span className="unit">кг</span>
</div>
<button type="button" className="btn rm-btn" onClick={() => bump(step)} aria-label="Плюс">
+
</button>
{action}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+844
View File
@@ -0,0 +1,844 @@
:root {
--bg0: #0f1412;
--bg1: #18201c;
--bg2: #223028;
--line: #2f3f36;
--text: #eef3ef;
--muted: #9aada2;
--accent: #c4f04d;
--accent-dim: #8fb82f;
--danger: #ff6b5a;
--ok: #7dffb3;
--radius: 14px;
--font: "Segoe UI", "Manrope", system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(1200px 600px at 10% -10%, #1d3324 0%, transparent 55%),
radial-gradient(900px 500px at 100% 0%, #24361f 0%, transparent 50%),
linear-gradient(180deg, #101612, #0c100e);
font-family: var(--font);
line-height: 1.4;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
min-width: 320px;
overflow: hidden;
}
button,
input {
font: inherit;
}
a {
color: inherit;
}
.app-shell {
height: 100dvh;
max-height: 100dvh;
max-width: 480px;
margin: 0 auto;
padding-top: max(28px, env(safe-area-inset-top, 0px));
overflow: hidden;
display: flex;
flex-direction: column;
background: #0f1412;
}
.page {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 16px 16px calc(24px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-page {
min-height: 100%;
}
.settings-foot {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.brand {
margin: 0;
font-size: 1.35rem;
font-weight: 750;
letter-spacing: -0.02em;
}
.brand-block {
padding: 12px 4px 4px;
}
.muted {
color: var(--muted);
margin: 4px 0 0;
}
.hint {
display: block;
color: var(--muted);
font-size: 0.85rem;
margin-top: 6px;
}
.error {
color: var(--danger);
margin: 0;
}
.ok {
color: var(--ok);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.topbar-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.card-form,
.phase-banner,
.active-set,
.prompt-box,
.settings-list {
background: color-mix(in srgb, var(--bg1) 88%, black);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
}
.card-form,
.settings-list,
.prompt-box,
.active-set,
.phase-banner {
display: flex;
flex-direction: column;
gap: 12px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.92rem;
}
input[type="number"],
input[type="password"],
input[type="date"],
input[type="text"] {
width: 100%;
border: 1px solid var(--line);
background: var(--bg0);
color: var(--text);
border-radius: 10px;
padding: 12px 12px;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
}
legend {
margin-bottom: 8px;
}
.btn {
border: 1px solid var(--line);
background: var(--bg2);
color: var(--text);
border-radius: 12px;
padding: 12px 14px;
cursor: pointer;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.primary {
background: linear-gradient(180deg, var(--accent), var(--accent-dim));
color: #10140f;
border-color: transparent;
font-weight: 700;
}
.btn.ghost {
background: transparent;
}
.btn.danger {
background: color-mix(in srgb, var(--danger) 20%, var(--bg2));
border-color: color-mix(in srgb, var(--danger) 50%, var(--line));
color: #ffd2cc;
}
.btn.wide {
width: 100%;
padding: 16px;
font-size: 1.05rem;
}
.day-grid,
.row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border: 1px solid var(--line);
background: var(--bg0);
color: var(--text);
border-radius: 999px;
padding: 10px 12px;
cursor: pointer;
}
.chip.on {
background: color-mix(in srgb, var(--accent) 25%, var(--bg0));
border-color: var(--accent-dim);
}
.week-strip h2,
.calendar h2,
.exercise-list h2 {
margin: 0 0 10px;
font-size: 1rem;
}
.strip-row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(92px, 1fr);
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.strip-card {
border: 1px solid var(--line);
background: var(--bg1);
color: var(--text);
border-radius: 12px;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
cursor: pointer;
}
.strip-card.today {
outline: 2px solid var(--accent);
}
.strip-card.done {
border-color: color-mix(in srgb, var(--ok) 40%, var(--line));
}
.strip-card.active {
border-color: var(--accent-dim);
}
.strip-day {
font-size: 0.8rem;
color: var(--muted);
}
.strip-date {
font-size: 1.3rem;
font-weight: 700;
}
.strip-meta {
font-size: 0.75rem;
color: var(--muted);
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.cal-nav h2 {
margin: 0;
text-transform: capitalize;
font-size: 1.05rem;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.cal-grid.head {
margin-bottom: 4px;
color: var(--muted);
font-size: 0.78rem;
text-align: center;
}
.cal-cell {
aspect-ratio: 1;
border: 1px solid transparent;
background: transparent;
color: var(--text);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
}
.cal-cell:disabled {
opacity: 0.55;
}
.cal-cell.planned,
.cal-cell.done,
.cal-cell.active,
.cal-cell.missed {
background: var(--bg1);
border-color: var(--line);
cursor: pointer;
}
.cal-cell.today {
box-shadow: inset 0 0 0 1px var(--accent);
}
.cal-cell .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.cal-cell.done .dot {
background: var(--ok);
}
.cal-cell.picked {
box-shadow: inset 0 0 0 2px var(--accent);
}
.cal-grid:not(.head) {
touch-action: none;
}
.cal-cell.drop {
box-shadow: inset 0 0 0 2px #3d7ea6;
}
.cal-cell.dragging {
opacity: 0.45;
}
.drag-ghost {
position: fixed;
z-index: 30;
width: 44px;
height: 44px;
margin-left: -22px;
margin-top: -46px;
border-radius: 10px;
display: grid;
place-items: center;
pointer-events: none;
background: var(--bg2);
border: 2px solid #3d7ea6;
font-weight: 700;
}
.workout-page {
min-height: 100%;
}
.workout-foot {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.btn.move {
background: #3d7ea6;
border-color: transparent;
color: #f4fbff;
font-weight: 700;
}
.rest-until {
display: flex;
flex-direction: column;
gap: 2px;
}
.rest-title {
margin: 0;
font-size: 1.15rem;
font-weight: 750;
}
.rest-until p {
margin: 0;
}
.move-prompt {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
}
.move-calendar {
margin-top: 4px;
}
.status-line {
font-variant-numeric: tabular-nums;
}
.active-set .ex-name {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
}
.set-label {
margin: 0;
color: var(--muted);
}
.big-numbers {
display: flex;
justify-content: space-around;
gap: 12px;
padding: 10px 0 4px;
}
.big-numbers .num {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.big-numbers .unit {
display: block;
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
.exercise-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.ex-block {
background: color-mix(in srgb, var(--bg1) 88%, black);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0;
overflow: hidden;
}
.ex-block.current {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}
.ex-block.finished:not(.current) {
opacity: 0.78;
}
.ex-head {
display: flex;
align-items: center;
}
.ex-toggle {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 0;
background: transparent;
color: var(--text);
padding: 14px 8px 14px 16px;
cursor: pointer;
text-align: left;
}
.icon-btn {
width: 44px;
height: 44px;
padding: 0;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 12px;
background: transparent;
color: var(--text);
cursor: pointer;
}
.ex-edit {
flex-shrink: 0;
width: 36px;
height: 36px;
margin-right: 10px;
padding: 0;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 10px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.ex-edit.on {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
}
.ex-toggle-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ex-toggle-main strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ex-toggle-meta {
flex-shrink: 0;
font-size: 0.85rem;
}
.chevron {
color: var(--muted);
width: 1rem;
}
.badge-now,
.badge-done {
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 2px 8px;
flex-shrink: 0;
}
.badge-now {
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--accent);
}
.badge-done {
background: color-mix(in srgb, var(--ok) 18%, transparent);
color: var(--ok);
}
.ex-body {
padding: 14px 16px 14px;
border-top: 1px solid var(--line);
}
.ex-body .active-set.nested,
.ex-body .prompt-box.nested {
margin: 0 0 12px;
border-radius: 12px;
}
.ex-body ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.ex-body li {
color: var(--muted);
font-size: 1.05rem;
line-height: 1.4;
min-height: 40px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.set-adjust {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.set-step {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg2);
color: var(--text);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.set-step:disabled {
opacity: 0.45;
}
.set-step-label {
min-width: 2.4rem;
text-align: center;
font-size: 0.75rem;
color: var(--muted);
}
.ex-body li.done {
color: var(--ok);
}
.weight-stepper {
display: flex;
flex-direction: column;
gap: 8px;
}
.weight-stepper-label {
font-size: 0.92rem;
}
.weight-stepper-row {
display: grid;
grid-template-columns: 44px 1fr 44px;
gap: 8px;
align-items: center;
}
.weight-stepper .rm-btn {
height: 40px;
font-size: 1.25rem;
}
.weight-stepper-value {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 2px 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg0);
}
.weight-stepper-value .num {
font-size: 1.25rem;
font-weight: 800;
line-height: 1.1;
}
.weight-stepper-value .unit {
font-size: 0.72rem;
}
.weight-stepper.compact .weight-stepper-row {
display: flex;
justify-content: flex-start;
}
.weight-stepper.compact .weight-stepper-value {
width: 76px;
flex: 0 0 76px;
padding: 0 4px;
}
.weight-stepper.compact .rm-btn {
width: 40px;
flex: 0 0 40px;
}
.check-btn {
width: 40px;
height: 40px;
flex: 0 0 40px;
border-radius: 10px;
background: var(--accent);
border-color: transparent;
color: #10140f;
}
.check-btn:disabled {
opacity: 0.45;
}
.auth-page {
min-height: 100%;
justify-content: center;
}
.stepper {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.step-dot {
border: 1px solid var(--line);
background: var(--bg1);
color: var(--muted);
border-radius: 999px;
padding: 8px 10px;
font-size: 0.85rem;
cursor: pointer;
}
.step-dot.on {
color: #10140f;
background: var(--accent);
border-color: transparent;
font-weight: 700;
}
.step-dot.done {
border-color: var(--accent-dim);
color: var(--text);
}
.onboard-step h1 {
margin: 0;
font-size: 1.35rem;
}
.rm-picker {
display: grid;
grid-template-columns: 64px 1fr 64px;
gap: 10px;
align-items: center;
margin: 8px 0 4px;
}
.rm-btn {
height: 64px;
font-size: 1.8rem;
font-weight: 700;
padding: 0;
}
.rm-value {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.rm-value input {
text-align: center;
font-size: 2rem;
font-weight: 800;
padding: 14px 10px;
}
.rm-value span {
color: var(--muted);
font-size: 0.9rem;
}
.day-grid-lg {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.chip-lg {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 12px;
padding: 14px 16px;
font-size: 1.05rem;
text-align: left;
}
.chip-check {
color: var(--accent);
font-weight: 700;
}
.summary-box {
background: var(--bg0);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
}
.summary-box p {
margin: 0 0 6px;
}
.summary-box p:last-child {
margin: 0;
}
@media (min-width: 480px) {
.app-shell {
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
}
}
+711
View File
@@ -0,0 +1,711 @@
import { ApiError, type Exercise, type Profile, type WorkoutDetail, type WorkoutSummary } from "../apiTypes";
import { db, ensureProfile } from "./db";
import { planMove, planWeekdayChange } from "./reschedule";
import {
applyAccessoryWeightToFuture,
generateSchedule,
todayIso,
weightStepFor,
} from "./schedule";
import type { StoredExercise, StoredWorkout } from "./types";
import { PAUSE_LIMIT_MINUTES, roundExerciseWeight, roundToStep, ACCESSORY_STEP_KG } from "./weights";
async function hashPassword(password: string): Promise<string> {
const data = new TextEncoder().encode(`bps:${password}`);
const buf = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function isoNow(): string {
return new Date().toISOString();
}
function addDaysIso(isoDate: string, days: number): string {
const d = new Date(isoDate + "T12:00:00");
d.setDate(d.getDate() + days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function profileView(p: Awaited<ReturnType<typeof ensureProfile>>): Profile {
const now = Date.now();
let restCountdown: number | null = null;
let canProbe = false;
let phase = p.phase;
if (p.phase === "rest" && p.probe_available_at) {
const left = Math.floor((new Date(p.probe_available_at).getTime() - now) / 1000);
if (left > 0) restCountdown = left;
else {
canProbe = true;
phase = "probe";
}
} else if (p.phase === "probe") {
canProbe = true;
}
return {
one_rm_kg: p.one_rm_kg,
training_weekdays: p.training_weekdays,
start_date: p.start_date,
onboarded: p.onboarded,
phase,
rest_until: p.rest_until,
probe_available_at: p.probe_available_at,
cycle_number: p.cycle_number,
rest_countdown_seconds: restCountdown,
can_probe: canProbe,
};
}
function summaryOf(w: StoredWorkout): WorkoutSummary {
return {
id: w.id,
cycle_number: w.cycle_number,
week_number: w.week_number,
day_index: w.day_index,
scheduled_date: w.scheduled_date,
status: w.status,
exercise_count: w.exercises.length,
};
}
function expirePause(w: StoredWorkout): StoredWorkout {
if (w.status === "paused" && w.paused_at) {
const limit = new Date(w.paused_at).getTime() + PAUSE_LIMIT_MINUTES * 60_000;
if (Date.now() > limit) {
w.status = "abandoned";
w.paused_at = null;
w.rest_started_at = null;
}
}
return w;
}
function toExercise(ex: StoredExercise): Exercise {
return {
id: ex.id,
order_index: ex.order_index,
name: ex.name,
kind: ex.kind,
prescribed_weight_kg: ex.prescribed_weight_kg,
weight_locked: ex.weight_locked,
needs_weight_input: ex.needs_weight_input,
pending_prompt: ex.pending_prompt,
weight_step_kg: weightStepFor(ex.name, ex.kind),
sets: ex.sets
.slice()
.sort((a, b) => a.set_index - b.set_index)
.map((s) => ({
id: s.id,
set_index: s.set_index,
target_percent: s.target_percent,
target_reps: s.target_reps,
target_weight_kg: s.target_weight_kg,
is_failure: s.is_failure,
completed: s.completed,
actual_reps: s.actual_reps,
actual_weight_kg: s.actual_weight_kg,
})),
};
}
async function syncAccessories(w: StoredWorkout): Promise<StoredWorkout> {
let changed = false;
for (const ex of w.exercises) {
if (ex.kind !== "accessory") continue;
if (ex.sets.length > 0 && ex.sets.every((s) => s.completed)) continue;
const st = await db.accessories
.where({ cycle_number: w.cycle_number, exercise_name: ex.name })
.first();
if (!st) continue;
const weight = st.pending_weight_kg ?? st.weight_kg;
if (weight == null) continue;
if (
ex.prescribed_weight_kg !== weight ||
ex.needs_weight_input ||
ex.weight_locked !== st.locked
) {
ex.prescribed_weight_kg = weight;
ex.needs_weight_input = false;
ex.weight_locked = st.locked;
for (const s of ex.sets) {
if (!s.completed) s.target_weight_kg = weight;
}
changed = true;
}
}
if (changed) await db.workouts.put(w);
return w;
}
async function getWorkout(id: number): Promise<StoredWorkout> {
let w = await db.workouts.get(id);
if (!w) throw new ApiError(404, "Тренировка не найдена");
w = expirePause(w);
await db.workouts.put(w);
w = await syncAccessories(w);
return w;
}
function detailOf(w: StoredWorkout, all: StoredWorkout[]): WorkoutDetail {
const today = todayIso();
const now = Date.now();
const active = all.find(
(x) => x.id !== w.id && x.cycle_number === w.cycle_number && (x.status === "in_progress" || x.status === "paused"),
);
const canStart = w.status === "planned" && w.scheduled_date === today && !active;
let canResume = false;
let pauseExpires: string | null = null;
if (w.status === "paused" && w.paused_at) {
const exp = new Date(w.paused_at).getTime() + PAUSE_LIMIT_MINUTES * 60_000;
pauseExpires = new Date(exp).toISOString();
canResume = now <= exp && w.scheduled_date === today;
}
let restElapsed = 0;
if (w.status === "in_progress" && w.rest_started_at) {
restElapsed = Math.max(0, Math.floor((now - new Date(w.rest_started_at).getTime()) / 1000));
}
return {
id: w.id,
cycle_number: w.cycle_number,
week_number: w.week_number,
day_index: w.day_index,
scheduled_date: w.scheduled_date,
status: w.status,
started_at: w.started_at,
paused_at: w.paused_at,
completed_at: w.completed_at,
current_exercise_index: w.current_exercise_index,
current_set_index: w.current_set_index,
rest_started_at: w.rest_started_at,
rest_elapsed_seconds: restElapsed,
can_start: canStart,
can_resume: canResume,
pause_expires_at: pauseExpires,
exercises: w.exercises.slice().sort((a, b) => a.order_index - b.order_index).map(toExercise),
};
}
function advancePointer(w: StoredWorkout): boolean {
const exercises = w.exercises.slice().sort((a, b) => a.order_index - b.order_index);
for (let ei = 0; ei < exercises.length; ei++) {
const sets = exercises[ei].sets.slice().sort((a, b) => a.set_index - b.set_index);
for (const s of sets) {
if (!s.completed) {
w.current_exercise_index = ei;
w.current_set_index = s.set_index;
return false;
}
}
}
return true;
}
async function maybeFinishCycle(w: StoredWorkout): Promise<void> {
const others = await db.workouts
.where("cycle_number")
.equals(w.cycle_number)
.filter((x) => x.id !== w.id && !["completed", "abandoned"].includes(x.status))
.count();
if (others === 0) {
const p = await ensureProfile();
p.phase = "rest";
p.probe_available_at = null;
p.rest_until = null;
await db.profile.put(p);
}
}
export const localApi = {
async status() {
const auth = await db.auth.get(1);
const p = await ensureProfile();
return { has_password: !!auth, onboarded: p.onboarded, phase: p.phase };
},
async setup(password: string) {
if (await db.auth.get(1)) throw new ApiError(400, "Пароль уже задан");
await db.auth.put({ id: 1, password_hash: await hashPassword(password) });
await ensureProfile();
const p = await ensureProfile();
return { access_token: "local", onboarded: p.onboarded };
},
async login(password: string) {
const auth = await db.auth.get(1);
if (!auth) throw new ApiError(400, "Сначала задайте пароль");
if (auth.password_hash !== (await hashPassword(password))) throw new ApiError(401, "Неверный пароль");
const p = await ensureProfile();
return { access_token: "local", onboarded: p.onboarded };
},
async profile() {
return profileView(await ensureProfile());
},
async onboarding(body: { one_rm_kg: number; training_weekdays: number[]; start_date: string }) {
const p = await ensureProfile();
if (p.onboarded) throw new ApiError(400, "Онбординг уже пройден. Используйте сброс в настройках.");
const oneRm = roundToStep(body.one_rm_kg, 1.25);
p.one_rm_kg = oneRm;
p.training_weekdays = [...body.training_weekdays].sort((a, b) => a - b);
p.start_date = body.start_date;
p.onboarded = true;
p.phase = "active";
p.cycle_number = 1;
await db.profile.put(p);
await db.workouts.where("cycle_number").equals(1).delete();
await db.accessories.where("cycle_number").equals(1).delete();
await generateSchedule({
oneRmKg: oneRm,
trainingWeekdays: p.training_weekdays,
startDate: body.start_date,
cycleNumber: 1,
});
return profileView(p);
},
async calendar(year: number, month: number) {
const p = await ensureProfile();
const workouts = await db.workouts.where("cycle_number").equals(p.cycle_number).toArray();
const monthWorkouts = workouts
.filter((w) => {
const [y, m] = w.scheduled_date.split("-").map(Number);
return y === year && m === month;
})
.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date));
return {
year,
month,
workouts: monthWorkouts.map(summaryOf),
profile: profileView(p),
};
},
async upcoming() {
const p = await ensureProfile();
const workouts = await db.workouts
.where("cycle_number")
.equals(p.cycle_number)
.sortBy("scheduled_date");
return workouts.map(summaryOf);
},
async workout(id: number) {
const w = await getWorkout(id);
const all = await db.workouts.where("cycle_number").equals(w.cycle_number).toArray();
return detailOf(w, all);
},
async start(id: number) {
const w = await getWorkout(id);
const today = todayIso();
if (w.scheduled_date !== today) throw new ApiError(400, "Начать можно только сегодняшнюю тренировку");
if (w.status !== "planned") throw new ApiError(400, "Тренировку нельзя начать");
const active = await db.workouts
.where("cycle_number")
.equals(w.cycle_number)
.filter((x) => x.status === "in_progress" || x.status === "paused")
.first();
if (active) throw new ApiError(400, "Уже есть активная тренировка");
w.status = "in_progress";
w.started_at = isoNow();
w.current_exercise_index = 0;
w.current_set_index = 0;
await db.workouts.put(w);
return this.workout(id);
},
async pause(id: number) {
const w = await getWorkout(id);
if (w.status !== "in_progress") throw new ApiError(400, "Пауза доступна только во время тренировки");
w.status = "paused";
w.paused_at = isoNow();
w.rest_started_at = null;
await db.workouts.put(w);
return this.workout(id);
},
async resume(id: number) {
let w = await getWorkout(id);
if (w.status === "abandoned") throw new ApiError(400, "Тренировка остановлена: пауза дольше 15 минут");
if (w.status !== "paused") throw new ApiError(400, "Тренировка не на паузе");
if (w.scheduled_date !== todayIso()) throw new ApiError(400, "Продолжить можно только в тот же день");
if (!w.paused_at || Date.now() > new Date(w.paused_at).getTime() + PAUSE_LIMIT_MINUTES * 60_000) {
w.status = "abandoned";
await db.workouts.put(w);
throw new ApiError(400, "Тренировка остановлена: пауза дольше 15 минут");
}
w.status = "in_progress";
w.paused_at = null;
await db.workouts.put(w);
return this.workout(id);
},
async setWeight(workoutId: number, exerciseId: number, weightKg: number) {
const w = await getWorkout(workoutId);
if (w.status === "completed") throw new ApiError(400, "Выполненную тренировку менять нельзя");
const ex = w.exercises.find((e) => e.id === exerciseId);
if (!ex) throw new ApiError(404, "Упражнение не найдено");
if (ex.kind !== "accessory") throw new ApiError(400, "Вес задаётся только для вспомогательных");
if (ex.sets.length > 0 && ex.sets.every((s) => s.completed)) {
throw new ApiError(400, "Это упражнение уже выполнено");
}
const weight = roundExerciseWeight(weightKg, ex.name, ex.kind);
ex.prescribed_weight_kg = weight;
ex.needs_weight_input = false;
for (const s of ex.sets) {
if (!s.completed) s.target_weight_kg = weight;
}
await db.workouts.put(w);
await applyAccessoryWeightToFuture({
cycleNumber: w.cycle_number,
exerciseName: ex.name,
weightKg: weight,
fromDate: w.scheduled_date,
lock: false,
});
const fresh = await db.workouts.get(workoutId);
if (fresh) {
const current = fresh.exercises.find((e) => e.id === exerciseId);
if (current) {
current.prescribed_weight_kg = weight;
current.needs_weight_input = false;
for (const s of current.sets) {
if (!s.completed) s.target_weight_kg = weight;
}
await db.workouts.put(fresh);
}
}
return this.workout(workoutId);
},
async adjustSet(
workoutId: number,
exerciseId: number,
setId: number,
body: { actual_weight_kg: number; actual_reps: number },
) {
const w = await getWorkout(workoutId);
const ex = w.exercises.find((e) => e.id === exerciseId);
if (!ex) throw new ApiError(404, "Упражнение не найдено");
if (ex.kind !== "accessory") throw new ApiError(400, "Править можно только вспомогательные");
const set = ex.sets.find((s) => s.id === setId);
if (!set || !set.completed) throw new ApiError(400, "Можно править только выполненный подход");
set.actual_weight_kg = roundExerciseWeight(body.actual_weight_kg, ex.name, ex.kind);
set.actual_reps = Math.max(0, Math.round(body.actual_reps));
await db.workouts.put(w);
return this.workout(workoutId);
},
async moveWorkout(id: number, scheduledDate: string) {
const w = await getWorkout(id);
const p = await ensureProfile();
const weekdays = p.training_weekdays;
if (!weekdays || weekdays.length === 0) throw new ApiError(400, "Не заданы дни тренировок");
const all = await db.workouts.where("cycle_number").equals(w.cycle_number).toArray();
let assigned: Map<number, string>;
try {
assigned = planMove(all, id, scheduledDate, weekdays);
} catch (err) {
throw new ApiError(400, err instanceof Error ? err.message : "Не удалось перенести");
}
for (const item of all) {
const next = assigned.get(item.id);
if (next && next !== item.scheduled_date) {
item.scheduled_date = next;
await db.workouts.put(item);
}
}
return this.workout(id);
},
async updateTrainingDays(weekdays: number[]) {
const p = await ensureProfile();
if (!p.onboarded) throw new ApiError(400, "Сначала создайте программу");
const all = await db.workouts.where("cycle_number").equals(p.cycle_number).toArray();
const done = all
.filter((item) => item.status === "completed")
.map((item) => item.scheduled_date)
.sort();
let start = todayIso();
if (done.length) {
const after = addDaysIso(done[done.length - 1], 1);
if (after > start) start = after;
}
let assigned: Map<number, string>;
try {
assigned = planWeekdayChange(all, weekdays, start);
} catch (err) {
throw new ApiError(400, err instanceof Error ? err.message : "Не удалось обновить дни");
}
for (const item of all) {
const next = assigned.get(item.id);
if (next && next !== item.scheduled_date) {
item.scheduled_date = next;
await db.workouts.put(item);
}
}
p.training_weekdays = [...weekdays].sort((a, b) => a - b);
await db.profile.put(p);
return profileView(p);
},
async completeSet(id: number, body: { actual_reps?: number; actual_weight_kg?: number } = {}) {
const w = await getWorkout(id);
if (w.status !== "in_progress") throw new ApiError(400, "Тренировка не активна");
const exercises = w.exercises.slice().sort((a, b) => a.order_index - b.order_index);
const exercise = exercises[w.current_exercise_index];
if (!exercise) throw new ApiError(400, "Нет текущего упражнения");
if (exercise.pending_prompt) throw new ApiError(400, "Сначала ответьте на вопрос по упражнению");
if (exercise.kind === "accessory" && exercise.needs_weight_input) {
throw new ApiError(400, "Сначала введите рабочий вес");
}
let current =
exercise.sets.find((s) => s.set_index === w.current_set_index && !s.completed) ||
exercise.sets.slice().sort((a, b) => a.set_index - b.set_index).find((s) => !s.completed);
if (!current) throw new ApiError(400, "Нет активного подхода");
if (exercise.kind === "bench" || exercise.kind === "failure") {
current.completed = true;
current.completed_at = isoNow();
current.actual_weight_kg = current.target_weight_kg;
current.actual_reps = current.target_reps;
} else {
if (body.actual_reps == null) throw new ApiError(400, "Укажите выполненные повторения");
const weight = body.actual_weight_kg ?? exercise.prescribed_weight_kg;
if (weight == null) throw new ApiError(400, "Не задан вес");
current.completed = true;
current.completed_at = isoNow();
current.actual_reps = body.actual_reps;
current.actual_weight_kg = roundExerciseWeight(weight, exercise.name, exercise.kind);
}
if (exercise.sets.every((s) => s.completed) && exercise.kind === "accessory" && !exercise.weight_locked) {
const allHit = exercise.sets.every(
(s) => s.target_reps == null || (s.actual_reps ?? 0) >= s.target_reps,
);
exercise.pending_prompt = allHit ? "confirm_easy" : "choose_weight_missed";
w.rest_started_at = null;
await db.workouts.put(w);
return this.workout(id);
}
const done = advancePointer(w);
if (done) {
w.status = "completed";
w.completed_at = isoNow();
w.rest_started_at = null;
await db.workouts.put(w);
await maybeFinishCycle(w);
} else {
w.rest_started_at = isoNow();
await db.workouts.put(w);
}
return this.workout(id);
},
async prompt(
workoutId: number,
exerciseId: number,
body: {
choice: "easy_yes" | "easy_no";
weight_kg?: number;
increase_kg?: number;
decrease_kg?: number;
},
) {
const w = await getWorkout(workoutId);
if (w.status !== "in_progress") throw new ApiError(400, "Тренировка не активна");
const exercise = w.exercises.find((e) => e.id === exerciseId);
if (!exercise) throw new ApiError(404, "Упражнение не найдено");
if (!exercise.pending_prompt) throw new ApiError(400, "Нет активного вопроса");
const today = w.scheduled_date;
const currentWeight = exercise.prescribed_weight_kg;
const step = weightStepFor(exercise.name, exercise.kind);
const prompt = exercise.pending_prompt;
if (prompt === "confirm_easy") {
if (body.choice === "easy_no") {
if (currentWeight == null) throw new ApiError(400, "Вес не задан");
await applyAccessoryWeightToFuture({
cycleNumber: w.cycle_number,
exerciseName: exercise.name,
weightKg: currentWeight,
fromDate: today,
lock: true,
});
exercise.weight_locked = true;
exercise.pending_prompt = null;
} else {
exercise.pending_prompt = "choose_weight_easy";
await db.workouts.put(w);
return this.workout(workoutId);
}
} else if (prompt === "choose_weight_easy") {
if (currentWeight == null) throw new ApiError(400, "Текущий вес не задан");
if (body.increase_kg == null || body.increase_kg <= 0) {
throw new ApiError(400, "Укажите, на сколько поднять вес");
}
let bump = roundToStep(body.increase_kg, step);
if (bump < step) bump = step;
let weight = roundToStep(currentWeight + bump, step);
if (weight <= currentWeight) weight = roundToStep(currentWeight + step, step);
await applyAccessoryWeightToFuture({
cycleNumber: w.cycle_number,
exerciseName: exercise.name,
weightKg: weight,
fromDate: addDaysIso(today, 1),
lock: false,
});
exercise.pending_prompt = null;
} else if (prompt === "choose_weight_missed") {
if (currentWeight == null) throw new ApiError(400, "Текущий вес не задан");
if (body.decrease_kg == null || body.decrease_kg <= 0) {
throw new ApiError(400, "Укажите, на сколько снизить вес");
}
let drop = roundToStep(body.decrease_kg, step);
if (drop < step) drop = step;
let weight = roundToStep(currentWeight - drop, step);
if (weight >= currentWeight) weight = roundToStep(currentWeight - step, step);
if (weight < ACCESSORY_STEP_KG) weight = ACCESSORY_STEP_KG;
await applyAccessoryWeightToFuture({
cycleNumber: w.cycle_number,
exerciseName: exercise.name,
weightKg: weight,
fromDate: addDaysIso(today, 1),
lock: false,
});
exercise.pending_prompt = null;
} else {
throw new ApiError(400, "Неизвестный вопрос");
}
const done = advancePointer(w);
if (done) {
w.status = "completed";
w.completed_at = isoNow();
w.rest_started_at = null;
await db.workouts.put(w);
await maybeFinishCycle(w);
} else {
w.rest_started_at = isoNow();
await db.workouts.put(w);
}
return this.workout(workoutId);
},
async startRest(restDays: number) {
if (restDays < 3 || restDays > 5) throw new ApiError(400, "Отдых от 3 до 5 дней");
const p = await ensureProfile();
const available = new Date(Date.now() + restDays * 86400_000).toISOString();
p.phase = "rest";
p.probe_available_at = available;
p.rest_until = available;
await db.profile.put(p);
return profileView(p);
},
async probe(body: { one_rm_kg: number; start_date: string; training_weekdays?: number[] }) {
const p = await ensureProfile();
if (p.phase === "rest" && p.probe_available_at && Date.now() < new Date(p.probe_available_at).getTime()) {
throw new ApiError(400, "Ещё рано для проходки");
}
const weekdays = body.training_weekdays || p.training_weekdays;
if (!weekdays) throw new ApiError(400, "Не заданы дни тренировок");
const oneRm = roundToStep(body.one_rm_kg, 1.25);
const newCycle = p.cycle_number + 1;
p.one_rm_kg = oneRm;
p.training_weekdays = weekdays;
p.start_date = body.start_date;
p.cycle_number = newCycle;
p.phase = "active";
p.rest_until = null;
p.probe_available_at = null;
p.onboarded = true;
await db.profile.put(p);
await generateSchedule({
oneRmKg: oneRm,
trainingWeekdays: weekdays,
startDate: body.start_date,
cycleNumber: newCycle,
});
return profileView(p);
},
async export() {
const p = await ensureProfile();
const workouts = await db.workouts.toArray();
const accessories = await db.accessories.toArray();
return {
version: 1,
exported_at: isoNow(),
data: {
profile: {
one_rm_kg: p.one_rm_kg,
training_weekdays: p.training_weekdays,
start_date: p.start_date,
onboarded: p.onboarded,
phase: p.phase,
rest_until: p.rest_until,
probe_available_at: p.probe_available_at,
cycle_number: p.cycle_number,
},
workouts,
accessories,
},
};
},
async import(payload: { version: number; data: unknown }) {
if (payload.version !== 1) throw new ApiError(400, "Неподдерживаемая версия экспорта");
const data = payload.data as {
profile?: Record<string, unknown>;
workouts?: StoredWorkout[];
accessories?: { cycle_number: number; exercise_name: string; weight_kg: number | null; locked: boolean; pending_weight_kg: number | null }[];
};
await db.workouts.clear();
await db.accessories.clear();
const p = await ensureProfile();
const pr = data.profile || {};
p.one_rm_kg = (pr.one_rm_kg as number) ?? null;
p.training_weekdays = (pr.training_weekdays as number[]) ?? null;
p.start_date = (pr.start_date as string) ?? null;
p.onboarded = Boolean(pr.onboarded);
p.phase = (pr.phase as string) || "active";
p.rest_until = (pr.rest_until as string) ?? null;
p.probe_available_at = (pr.probe_available_at as string) ?? null;
p.cycle_number = Number(pr.cycle_number || 1);
await db.profile.put(p);
for (const a of data.accessories || []) {
await db.accessories.add({
cycle_number: a.cycle_number,
exercise_name: a.exercise_name,
weight_kg: a.weight_kg,
locked: a.locked,
pending_weight_kg: a.pending_weight_kg,
});
}
for (const w of data.workouts || []) {
await db.workouts.put(w);
}
return { message: "Данные импортированы" };
},
async reset() {
await db.workouts.clear();
await db.accessories.clear();
const p = await ensureProfile();
p.one_rm_kg = null;
p.training_weekdays = null;
p.start_date = null;
p.onboarded = false;
p.phase = "active";
p.rest_until = null;
p.probe_available_at = null;
p.cycle_number = 1;
await db.profile.put(p);
return { message: "Прогресс удалён. Пройдите онбординг заново." };
},
};
+51
View File
@@ -0,0 +1,51 @@
import Dexie, { type Table } from "dexie";
import type { MetaRow, StoredAccessory, StoredAuth, StoredProfile, StoredWorkout } from "./types";
export class BenchpressDB extends Dexie {
auth!: Table<StoredAuth, number>;
profile!: Table<StoredProfile, number>;
workouts!: Table<StoredWorkout, number>;
accessories!: Table<StoredAccessory, number>;
meta!: Table<MetaRow, string>;
constructor() {
super("benchpress_secret_v2");
this.version(1).stores({
auth: "id",
profile: "id",
workouts: "++id, cycle_number, scheduled_date, status",
accessories: "++id, [cycle_number+exercise_name], cycle_number",
meta: "key",
});
}
}
export const db = new BenchpressDB();
export async function nextId(key: string): Promise<number> {
return db.transaction("rw", db.meta, async () => {
const row = await db.meta.get(key);
const next = (row?.value ?? 0) + 1;
await db.meta.put({ key, value: next });
return next;
});
}
export async function ensureProfile(): Promise<StoredProfile> {
let p = await db.profile.get(1);
if (!p) {
p = {
id: 1,
one_rm_kg: null,
training_weekdays: null,
start_date: null,
onboarded: false,
phase: "active",
rest_until: null,
probe_available_at: null,
cycle_number: 1,
};
await db.profile.put(p);
}
return p;
}
+132
View File
@@ -0,0 +1,132 @@
export type Slot = {
id: number;
week_number: number;
day_index: number;
scheduled_date: string;
status: string;
};
function order(w: Slot): number {
return w.week_number * 10 + w.day_index;
}
function addDays(iso: string, days: number): string {
const d = new Date(iso + "T12:00:00");
d.setDate(d.getDate() + days);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function weekdayMon(iso: string): number {
const js = new Date(iso + "T12:00:00").getDay();
return js === 0 ? 6 : js - 1;
}
function stepToTrainingDay(iso: string, weekdays: number[], direction: 1 | -1): string {
const selected = new Set(weekdays);
let cursor = addDays(iso, direction);
for (let i = 0; i < 28; i++) {
if (selected.has(weekdayMon(cursor))) return cursor;
cursor = addDays(cursor, direction);
}
throw new Error("Не удалось найти тренировочный день");
}
function firstTrainingOnOrAfter(iso: string, weekdays: number[]): string {
if (new Set(weekdays).has(weekdayMon(iso))) return iso;
return stepToTrainingDay(addDays(iso, -1), weekdays, 1);
}
function isOpen(w: Slot): boolean {
return w.status !== "completed";
}
function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function nextTraining(after: string, weekdays: number[], taken: Set<string>): string {
let date = stepToTrainingDay(after, weekdays, 1);
while (taken.has(date)) date = stepToTrainingDay(date, weekdays, 1);
return date;
}
function placeInOrder(desired: string, previous: string | undefined, weekdays: number[], taken: Set<string>): string {
let date = desired;
if (previous && date <= previous) date = nextTraining(previous, weekdays, taken);
while (taken.has(date)) date = nextTraining(date, weekdays, taken);
return date;
}
export function planMove(workouts: Slot[], movedId: number, newDate: string, weekdays: number[]): Map<number, string> {
const moved = workouts.find((w) => w.id === movedId);
if (!moved) throw new Error("Тренировка не найдена");
if (!isOpen(moved)) throw new Error("Выполненную тренировку переносить нельзя");
if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) throw new Error("Некорректная дата");
if (newDate < todayIso()) throw new Error("В закрытый период переносить нельзя");
const sorted = [...workouts].sort((a, b) => order(a) - order(b) || a.id - b.id);
const completed = sorted.filter((w) => !isOpen(w));
if (completed.some((w) => w.scheduled_date === newDate)) {
throw new Error("На эту дату уже есть выполненная тренировка");
}
const earlierDone = completed.filter((w) => order(w) < order(moved));
const laterDone = completed.filter((w) => order(w) > order(moved));
const lastEarlier = earlierDone.at(-1);
const firstLater = laterDone[0];
if (lastEarlier && newDate <= lastEarlier.scheduled_date) {
throw new Error("Дата должна быть позже уже выполненной тренировки");
}
if (firstLater && newDate >= firstLater.scheduled_date) {
throw new Error("Дата должна быть раньше уже выполненной следующей тренировки");
}
const taken = new Set(completed.map((w) => w.scheduled_date));
const assigned = new Map<number, string>();
let index = 0;
while (index < sorted.length) {
if (!isOpen(sorted[index])) {
index += 1;
continue;
}
const start = index;
while (index < sorted.length && isOpen(sorted[index])) index += 1;
const segment = sorted.slice(start, index);
const prevAnchor = start > 0 ? sorted[start - 1] : undefined;
const nextAnchor = index < sorted.length ? sorted[index] : undefined;
const proposed = segment.map((w) => (w.id === movedId ? newDate : w.scheduled_date)).sort();
const dates: string[] = [];
for (const raw of proposed) {
const date = placeInOrder(raw, dates.at(-1), weekdays, taken);
if (prevAnchor && date <= prevAnchor.scheduled_date) {
throw new Error("Дата должна быть позже уже выполненной тренировки");
}
if (nextAnchor && date >= nextAnchor.scheduled_date) {
throw new Error("Дата должна быть раньше уже выполненной следующей тренировки");
}
dates.push(date);
taken.add(date);
}
segment.forEach((w, slot) => assigned.set(w.id, dates[slot]));
}
return assigned;
}
export function planWeekdayChange(workouts: Slot[], weekdays: number[], startFrom: string): Map<number, string> {
const open = workouts.filter(isOpen).sort((a, b) => order(a) - order(b));
const taken = new Set(workouts.filter((w) => !isOpen(w)).map((w) => w.scheduled_date));
const assigned = new Map<number, string>();
let cursor = startFrom;
for (const item of open) {
let date = firstTrainingOnOrAfter(cursor, weekdays);
while (taken.has(date)) date = stepToTrainingDay(date, weekdays, 1);
assigned.set(item.id, date);
taken.add(date);
cursor = addDays(date, 1);
}
return assigned;
}
+217
View File
@@ -0,0 +1,217 @@
import programData from "../data/program.json" with { type: "json" };
import { benchWeight, weightStepFor } from "./weights";
import type { StoredAccessory, StoredExercise, StoredSet, StoredWorkout } from "./types";
import { db, nextId } from "./db";
type ProgramSet = { percent?: number; reps?: number; type?: string };
type ProgramExercise = { name: string; kind: string; sets: ProgramSet[] };
type ProgramDay = { template_day: string; label: string; exercises: ProgramExercise[] };
type ProgramWeek = { week: number; days: ProgramDay[] };
const program = programData as { weeks: ProgramWeek[] };
export function getProgram(): { weeks: ProgramWeek[] } {
return program;
}
export function nextDatesForWeekdays(startIso: string, weekdays: number[], count: number): string[] {
const selected = new Set(weekdays);
const dates: string[] = [];
const cursor = new Date(startIso + "T12:00:00");
for (let i = 0; i < 400 && dates.length < count; i++) {
const jsDay = cursor.getDay();
const monBased = jsDay === 0 ? 6 : jsDay - 1;
if (selected.has(monBased)) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, "0");
const d = String(cursor.getDate()).padStart(2, "0");
dates.push(`${y}-${m}-${d}`);
}
cursor.setDate(cursor.getDate() + 1);
}
if (dates.length < count) throw new Error("Не удалось построить расписание");
return dates;
}
async function getAccessory(
cycle: number,
name: string,
): Promise<StoredAccessory | undefined> {
return db.accessories.where({ cycle_number: cycle, exercise_name: name }).first();
}
export async function generateSchedule(opts: {
oneRmKg: number;
trainingWeekdays: number[];
startDate: string;
cycleNumber: number;
}): Promise<StoredWorkout[]> {
const prog = getProgram();
const totalDays = prog.weeks.reduce((n, w) => n + w.days.length, 0);
const dates = nextDatesForWeekdays(opts.startDate, opts.trainingWeekdays, totalDays);
let dateIdx = 0;
const created: StoredWorkout[] = [];
for (const week of prog.weeks) {
for (let dayIndex = 0; dayIndex < week.days.length; dayIndex++) {
const day = week.days[dayIndex];
const scheduled = dates[dateIdx++];
const workoutId = await nextId("workout");
const exercises: StoredExercise[] = [];
for (let order = 0; order < day.exercises.length; order++) {
const ex = day.exercises[order];
let needsWeight = false;
let prescribed: number | null = null;
let locked = false;
if (ex.kind === "accessory") {
const st = await getAccessory(opts.cycleNumber, ex.name);
if (st?.locked && st.weight_kg != null) {
prescribed = st.weight_kg;
locked = true;
} else if (st?.pending_weight_kg != null) {
prescribed = st.pending_weight_kg;
} else if (st?.weight_kg != null) {
prescribed = st.weight_kg;
} else {
needsWeight = true;
}
}
const exId = await nextId("exercise");
const sets: StoredSet[] = [];
for (let si = 0; si < ex.sets.length; si++) {
const s = ex.sets[si];
const setId = await nextId("set");
if (ex.kind === "bench") {
sets.push({
id: setId,
set_index: si,
target_percent: s.percent ?? null,
target_reps: s.reps ?? null,
target_weight_kg: benchWeight(opts.oneRmKg, s.percent ?? 0),
is_failure: false,
completed: false,
actual_reps: null,
actual_weight_kg: null,
completed_at: null,
});
} else if (ex.kind === "failure") {
sets.push({
id: setId,
set_index: si,
target_percent: null,
target_reps: null,
target_weight_kg: null,
is_failure: true,
completed: false,
actual_reps: null,
actual_weight_kg: null,
completed_at: null,
});
} else {
sets.push({
id: setId,
set_index: si,
target_percent: null,
target_reps: s.reps ?? null,
target_weight_kg: prescribed,
is_failure: false,
completed: false,
actual_reps: null,
actual_weight_kg: null,
completed_at: null,
});
}
}
exercises.push({
id: exId,
order_index: order,
name: ex.name,
kind: ex.kind,
prescribed_weight_kg: prescribed,
weight_locked: locked,
needs_weight_input: needsWeight,
pending_prompt: null,
sets,
});
}
const workout: StoredWorkout = {
id: workoutId,
cycle_number: opts.cycleNumber,
week_number: week.week,
day_index: dayIndex,
scheduled_date: scheduled,
status: "planned",
started_at: null,
paused_at: null,
completed_at: null,
current_exercise_index: 0,
current_set_index: 0,
rest_started_at: null,
exercises,
};
await db.workouts.put(workout);
created.push(workout);
}
}
return created;
}
export async function applyAccessoryWeightToFuture(opts: {
cycleNumber: number;
exerciseName: string;
weightKg: number;
fromDate: string;
lock: boolean;
}): Promise<void> {
let st = await getAccessory(opts.cycleNumber, opts.exerciseName);
if (!st) {
st = {
cycle_number: opts.cycleNumber,
exercise_name: opts.exerciseName,
weight_kg: null,
locked: false,
pending_weight_kg: null,
};
}
if (opts.lock) {
st.weight_kg = opts.weightKg;
st.locked = true;
st.pending_weight_kg = null;
} else {
st.weight_kg = opts.weightKg;
st.pending_weight_kg = opts.weightKg;
st.locked = false;
}
if (st.id != null) await db.accessories.put(st);
else await db.accessories.add(st);
const workouts = await db.workouts.where("cycle_number").equals(opts.cycleNumber).toArray();
for (const w of workouts) {
if (w.status === "completed" || w.scheduled_date < opts.fromDate) continue;
let changed = false;
for (const ex of w.exercises) {
if (ex.name !== opts.exerciseName || ex.kind !== "accessory") continue;
if (ex.sets.length > 0 && ex.sets.every((s) => s.completed)) continue;
ex.prescribed_weight_kg = opts.weightKg;
ex.weight_locked = opts.lock;
ex.needs_weight_input = false;
for (const s of ex.sets) {
if (!s.completed) s.target_weight_kg = opts.weightKg;
}
changed = true;
}
if (changed) await db.workouts.put(w);
}
}
export function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export { weightStepFor };
+71
View File
@@ -0,0 +1,71 @@
export type StoredSet = {
id: number;
set_index: number;
target_percent: number | null;
target_reps: number | null;
target_weight_kg: number | null;
is_failure: boolean;
completed: boolean;
actual_reps: number | null;
actual_weight_kg: number | null;
completed_at: string | null;
};
export type StoredExercise = {
id: number;
order_index: number;
name: string;
kind: string;
prescribed_weight_kg: number | null;
weight_locked: boolean;
needs_weight_input: boolean;
pending_prompt: string | null;
sets: StoredSet[];
};
export type StoredWorkout = {
id: number;
cycle_number: number;
week_number: number;
day_index: number;
scheduled_date: string;
status: string;
started_at: string | null;
paused_at: string | null;
completed_at: string | null;
current_exercise_index: number;
current_set_index: number;
rest_started_at: string | null;
exercises: StoredExercise[];
};
export type StoredProfile = {
id: number;
one_rm_kg: number | null;
training_weekdays: number[] | null;
start_date: string | null;
onboarded: boolean;
phase: string;
rest_until: string | null;
probe_available_at: string | null;
cycle_number: number;
};
export type StoredAuth = {
id: number;
password_hash: string;
};
export type StoredAccessory = {
id?: number;
cycle_number: number;
exercise_name: string;
weight_kg: number | null;
locked: boolean;
pending_weight_kg: number | null;
};
export type MetaRow = {
key: string;
value: number;
};
+21
View File
@@ -0,0 +1,21 @@
export const PLATE_STEP_KG = 1.25;
export const ACCESSORY_STEP_KG = 0.5;
export const PAUSE_LIMIT_MINUTES = 15;
export function weightStepFor(name: string, kind: string): number {
if (kind === "bench" || name === "Жим") return PLATE_STEP_KG;
return ACCESSORY_STEP_KG;
}
export function roundToStep(weightKg: number, step: number): number {
if (weightKg <= 0) return 0;
return Math.round(Math.round(weightKg / step) * step * 100) / 100;
}
export function roundExerciseWeight(weightKg: number, name: string, kind: string): number {
return roundToStep(weightKg, weightStepFor(name, kind));
}
export function benchWeight(oneRmKg: number, percent: number): number {
return roundToStep(oneRmKg * (percent / 100), PLATE_STEP_KG);
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api, formatDhms, type Profile, type WorkoutSummary, WEEKDAY_LABELS } from "../api";
import { AppCalendar } from "../components/AppCalendar";
function startOfMonth(year: number, month: number) {
return new Date(year, month - 1, 1);
}
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate();
}
function iso(d: Date) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function weekStrip(workouts: WorkoutSummary[], todayIso: string): WorkoutSummary[] {
const today = new Date(todayIso);
const day = today.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const from = iso(monday);
const to = iso(sunday);
return workouts.filter((w) => w.scheduled_date >= from && w.scheduled_date <= to);
}
export function HomePage() {
const navigate = useNavigate();
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1);
const [workouts, setWorkouts] = useState<WorkoutSummary[]>([]);
const [allWorkouts, setAllWorkouts] = useState<WorkoutSummary[]>([]);
const [profile, setProfile] = useState<Profile | null>(null);
const [error, setError] = useState("");
const [restDays, setRestDays] = useState(3);
const [probeRm, setProbeRm] = useState("");
const [probeStart, setProbeStart] = useState(() => iso(new Date()));
const [countdown, setCountdown] = useState(0);
const [drag, setDrag] = useState<{
id: number;
from: string;
originX: number;
originY: number;
x: number;
y: number;
hover: string | null;
moved: boolean;
} | null>(null);
const dragRef = useRef(drag);
const suppressClick = useRef(false);
const load = async () => {
try {
const [cal, upcoming] = await Promise.all([api.calendar(year, month), api.upcoming()]);
setWorkouts(cal.workouts);
setAllWorkouts(upcoming);
setProfile(cal.profile);
setCountdown(cal.profile.rest_countdown_seconds || 0);
if (cal.profile.one_rm_kg) setProbeRm(String(cal.profile.one_rm_kg));
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
}
};
useEffect(() => {
load();
}, [year, month]);
useEffect(() => {
if (!profile || profile.phase !== "rest" || profile.can_probe) return;
const t = setInterval(() => setCountdown((c) => Math.max(0, c - 1)), 1000);
return () => clearInterval(t);
}, [profile?.phase, profile?.can_probe]);
const byDate = useMemo(() => {
const map = new Map<string, WorkoutSummary>();
for (const w of workouts) map.set(w.scheduled_date, w);
return map;
}, [workouts]);
const todayIso = iso(new Date());
const strip = weekStrip(allWorkouts, todayIso);
const firstWeekday = (startOfMonth(year, month).getDay() + 6) % 7; // Mon=0
const totalDays = daysInMonth(year, month);
const shiftMonth = (delta: number) => {
const d = new Date(year, month - 1 + delta, 1);
setYear(d.getFullYear());
setMonth(d.getMonth() + 1);
};
const statusClass = (s: string) => {
if (s === "completed") return "done";
if (s === "in_progress" || s === "paused") return "active";
if (s === "abandoned") return "missed";
return "planned";
};
return (
<div className="page home-page">
<header className="topbar">
<div>
<p className="brand">Benchpress Secret</p>
{profile?.one_rm_kg != null && <p className="muted">1ПМ: {profile.one_rm_kg} кг · цикл {profile.cycle_number}</p>}
</div>
<Link className="icon-btn" to="/settings" aria-label="Настройки">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.63-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54c-.58.23-1.12.54-1.63.94l-2.39-.96a.5.5 0 0 0-.6.22L2.71 8.48a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32c.13.22.39.31.6.22l2.39-.96c.51.4 1.05.71 1.63.94l.36 2.54c.05.24.26.42.5.42h3.84c.24 0 .45-.18.5-.42l.36-2.54c.58-.23 1.12-.54 1.63-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z"
/>
</svg>
</Link>
</header>
{profile && (profile.phase === "rest" || profile.phase === "probe" || profile.can_probe) && (
<section className="phase-banner">
{!profile.can_probe && profile.phase === "rest" && profile.probe_available_at == null && (
<div>
<h2>Цикл завершён</h2>
<p>Выберите длительность отдыха (3–5 дней)</p>
<div className="row">
{[3, 4, 5].map((d) => (
<button
key={d}
type="button"
className={`chip ${restDays === d ? "on" : ""}`}
onClick={() => setRestDays(d)}
>
{d} дн.
</button>
))}
</div>
<button
className="btn primary"
type="button"
onClick={async () => {
const p = await api.startRest(restDays);
setProfile(p);
setCountdown(p.rest_countdown_seconds || 0);
}}
>
Начать отдых
</button>
</div>
)}
{!profile.can_probe && profile.probe_available_at && (
<div>
<h2>Отдых</h2>
<p>До проходки: {formatDhms(countdown)}</p>
</div>
)}
{profile.can_probe && (
<div>
<h2>Сделай проходку</h2>
<p>Введи новый одноповторный максимум</p>
<label>
Новый 1ПМ, кг
<input
type="number"
step="1.25"
value={probeRm}
onChange={(e) => setProbeRm(e.target.value)}
/>
</label>
<p className="muted">Дата старта нового цикла</p>
<AppCalendar value={probeStart} onPick={setProbeStart} />
<button
className="btn primary"
type="button"
onClick={async () => {
await api.probe({ one_rm_kg: Number(probeRm), start_date: probeStart });
await load();
}}
>
Сохранить и начать цикл
</button>
</div>
)}
</section>
)}
<section className="week-strip">
<h2>Эта неделя</h2>
<div className="strip-row">
{strip.length === 0 && <p className="muted">Нет тренировок на этой неделе</p>}
{strip.map((w) => (
<button
key={w.id}
type="button"
className={`strip-card ${statusClass(w.status)} ${w.scheduled_date === todayIso ? "today" : ""}`}
onClick={() => navigate(`/workout/${w.id}`)}
>
<span className="strip-day">{WEEKDAY_LABELS[(new Date(w.scheduled_date + "T12:00:00").getDay() + 6) % 7]}</span>
<span className="strip-date">{w.scheduled_date.slice(8)}</span>
<span className="strip-meta">Неделя {w.week_number}</span>
</button>
))}
</div>
</section>
<section className="calendar">
<div className="cal-nav">
<button type="button" className="btn ghost" onClick={() => shiftMonth(-1)}>
‹
</button>
<h2>
{now.toLocaleString("ru-RU", { month: "long" }) &&
new Date(year, month - 1, 1).toLocaleString("ru-RU", { month: "long", year: "numeric" })}
</h2>
<button type="button" className="btn ghost" onClick={() => shiftMonth(1)}>
›
</button>
</div>
<div className="cal-grid head">
{WEEKDAY_LABELS.map((d) => (
<div key={d}>{d}</div>
))}
</div>
<div className="cal-grid">
{Array.from({ length: firstWeekday }).map((_, i) => (
<div key={`e${i}`} className="cal-cell empty" />
))}
{Array.from({ length: totalDays }).map((_, i) => {
const day = i + 1;
const key = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const w = byDate.get(key);
const movable = !!w && w.status !== "completed" && w.status !== "abandoned";
return (
<button
key={key}
type="button"
data-date={key}
className={`cal-cell ${key === todayIso ? "today" : ""} ${w ? statusClass(w.status) : ""} ${drag?.hover === key ? "drop" : ""} ${drag?.from === key ? "dragging" : ""}`}
onPointerDown={(e) => {
if (!w || !movable) return;
e.currentTarget.setPointerCapture(e.pointerId);
const next = {
id: w.id,
from: key,
originX: e.clientX,
originY: e.clientY,
x: e.clientX,
y: e.clientY,
hover: key,
moved: false,
};
dragRef.current = next;
setDrag(next);
}}
onPointerMove={(e) => {
const current = dragRef.current;
if (!current) return;
const moved = current.moved || Math.hypot(e.clientX - current.originX, e.clientY - current.originY) > 8;
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el?.closest("[data-date]") as HTMLElement | null;
const next = { ...current, x: e.clientX, y: e.clientY, hover: cell?.dataset.date ?? null, moved };
dragRef.current = next;
setDrag(next);
}}
onPointerUp={() => {
const current = dragRef.current;
if (!current) return;
dragRef.current = null;
setDrag(null);
if (current.moved) {
suppressClick.current = true;
if (current.hover && current.hover !== current.from) {
api
.moveWorkout(current.id, current.hover)
.then(() => load())
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось перенести"));
}
}
}}
onPointerCancel={() => {
dragRef.current = null;
setDrag(null);
}}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
if (w) navigate(`/workout/${w.id}`);
}}
>
<span>{day}</span>
{w && <i className="dot" />}
</button>
);
})}
</div>
{drag?.moved && (
<div className="drag-ghost" style={{ left: drag.x, top: drag.y }}>
{Number(drag.from.slice(8))}
</div>
)}
</section>
{error && <p className="error">{error}</p>}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState, type FormEvent } from "react";
import { useAuth } from "../auth";
export function LoginPage() {
const { hasPassword, setup, login } = useAuth();
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setBusy(true);
try {
if (!hasPassword) {
if (password.length < 4) throw new Error("Минимум 4 символа");
if (password !== password2) throw new Error("Пароли не совпадают");
await setup(password);
} else {
await login(password);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setBusy(false);
}
};
return (
<div className="page auth-page">
<div className="brand-block">
<p className="brand">Benchpress Secret</p>
<p className="muted">Программа жима лёжа на 5 недель</p>
</div>
<form className="card-form" onSubmit={onSubmit}>
<h1>{hasPassword ? "Вход" : "Создайте пароль"}</h1>
<label>
Пароль
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={hasPassword ? "current-password" : "new-password"}
required
/>
</label>
{!hasPassword && (
<label>
Повтор пароля
<input
type="password"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
autoComplete="new-password"
required
/>
</label>
)}
{error && <p className="error">{error}</p>}
<button className="btn primary" disabled={busy} type="submit">
{busy ? "…" : hasPassword ? "Войти" : "Сохранить"}
</button>
</form>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useMemo, useState, type FormEvent } from "react";
import { api, WEEKDAY_FULL } from "../api";
import { AppCalendar } from "../components/AppCalendar";
import { useAuth } from "../auth";
function daysOk(selected: number[]): boolean {
if (selected.length !== 3) return false;
if (new Set(selected).size !== 3) return false;
const sorted = [...selected].sort((a, b) => a - b);
for (let i = 0; i < 3; i++) {
const a = sorted[i];
const b = sorted[(i + 1) % 3];
const gap = i < 2 ? b - a - 1 : a + 7 - b - 1;
if (gap < 1) return false;
}
return true;
}
function roundToPlate(value: number): number {
return Math.round(Math.round(value / 1.25) * 1.25 * 100) / 100;
}
function formatRm(value: number): string {
return String(value);
}
export function OnboardingPage() {
const { refresh } = useAuth();
const [step, setStep] = useState(0);
const [oneRm, setOneRm] = useState(100);
const [oneRmText, setOneRmText] = useState("100");
const [days, setDays] = useState<number[]>([]);
const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10));
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const daysValid = useMemo(() => daysOk(days), [days]);
const canFinish = daysValid && oneRm > 0 && !!startDate;
const toggleDay = (d: number) => {
setDays((prev) => {
if (prev.includes(d)) return prev.filter((x) => x !== d);
if (prev.length >= 3) return prev;
return [...prev, d];
});
};
const commitRm = (raw = oneRmText) => {
const n = Number(raw.trim().replace(",", "."));
const rounded = !Number.isFinite(n) || n <= 0 ? oneRm : Math.max(1.25, roundToPlate(n));
setOneRm(rounded);
setOneRmText(formatRm(rounded));
return rounded;
};
const bumpRm = (delta: number) => {
const base = commitRm();
const next = Math.max(1.25, roundToPlate(base + delta));
setOneRm(next);
setOneRmText(formatRm(next));
};
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!canFinish) return;
setBusy(true);
setError("");
try {
await api.onboarding({
one_rm_kg: commitRm(),
training_weekdays: days,
start_date: startDate,
});
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setBusy(false);
}
};
return (
<div className="page">
<div className="brand-block">
<p className="brand">Benchpress Secret</p>
<p className="muted">Шаг {step + 1} из 3 — настройка программы</p>
</div>
<div className="stepper">
{["1ПМ", "Дни", "Старт"].map((label, i) => (
<button
key={label}
type="button"
className={`step-dot ${i === step ? "on" : ""} ${i < step ? "done" : ""}`}
onClick={() => setStep(i)}
>
{label}
</button>
))}
</div>
<form className="card-form" onSubmit={onSubmit}>
{step === 0 && (
<section className="onboard-step">
<h1>Максимум на 1 повторение</h1>
<p className="muted">Жим лёжа, кг. Шаг блинов 1.25</p>
<div className="rm-picker">
<button type="button" className="btn rm-btn" onClick={() => bumpRm(-1.25)} aria-label="Minus">
−
</button>
<div className="rm-value">
<input
type="text"
inputMode="decimal"
value={oneRmText}
onChange={(e) => setOneRmText(e.target.value)}
onBlur={() => commitRm()}
required
/>
<span>кг</span>
</div>
<button type="button" className="btn rm-btn" onClick={() => bumpRm(1.25)} aria-label="Plus">
+
</button>
</div>
<button
type="button"
className="btn primary wide"
onClick={() => {
commitRm();
setStep(1);
}}
>
Далее — дни тренировок
</button>
</section>
)}
{step === 1 && (
<section className="onboard-step">
<h1>Выберите 3 дня</h1>
<p className="muted">Между соседними днями нужен минимум 1 день перерыва</p>
<div className="day-grid day-grid-lg">
{WEEKDAY_FULL.map((label, idx) => {
const on = days.includes(idx);
return (
<button
key={label}
type="button"
className={`chip chip-lg ${on ? "on" : ""}`}
onClick={() => toggleDay(idx)}
>
{label}
{on && <span className="chip-check">✓</span>}
</button>
);
})}
</div>
<p className="hint">Выбрано: {days.length} / 3</p>
{days.length === 3 && !daysValid && (
<p className="error">Между днями нужен минимум 1 день перерыва</p>
)}
<div className="row">
<button type="button" className="btn" onClick={() => setStep(0)}>
Назад
</button>
<button type="button" className="btn primary" disabled={!daysValid} onClick={() => setStep(2)}>
Далее — дата старта
</button>
</div>
</section>
)}
{step === 2 && (
<section className="onboard-step">
<h1>Дата старта</h1>
<p className="muted">С этой даты построим расписание на 5 недель</p>
<p className="muted">Первый день программы</p>
<AppCalendar value={startDate} onPick={setStartDate} />
<div className="summary-box">
<p>
<strong>1ПМ:</strong> {oneRm} кг
</p>
<p>
<strong>Дни:</strong>{" "}
{days
.slice()
.sort((a, b) => a - b)
.map((d) => WEEKDAY_FULL[d])
.join(", ")}
</p>
</div>
{error && <p className="error">{error}</p>}
<div className="row">
<button type="button" className="btn" onClick={() => setStep(1)}>
Назад
</button>
<button className="btn primary" disabled={!canFinish || busy} type="submit">
{busy ? "Создаём…" : "Создать расписание"}
</button>
</div>
</section>
)}
</form>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api, WEEKDAY_FULL, type Profile } from "../api";
import { useAuth } from "../auth";
function daysOk(selected: number[]): boolean {
if (selected.length !== 3) return false;
if (new Set(selected).size !== 3) return false;
const sorted = [...selected].sort((a, b) => a - b);
for (let i = 0; i < 3; i++) {
const a = sorted[i];
const b = sorted[(i + 1) % 3];
const gap = i < 2 ? b - a - 1 : a + 7 - b - 1;
if (gap < 1) return false;
}
return true;
}
function formatStart(iso: string): string {
const d = new Date(iso + "T12:00:00");
return d
.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })
.replace(/\s*г\.?$/, "");
}
export function SettingsPage() {
const { refresh } = useAuth();
const navigate = useNavigate();
const fileRef = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [editingDays, setEditingDays] = useState(false);
const [days, setDays] = useState<number[]>([]);
const [daysBusy, setDaysBusy] = useState(false);
useEffect(() => {
api
.profile()
.then((p) => {
setProfile(p);
setDays(p.training_weekdays ?? []);
})
.catch(() => undefined);
}, []);
const toggleDay = (d: number) => {
setDays((prev) => {
if (prev.includes(d)) return prev.filter((x) => x !== d);
if (prev.length >= 3) return prev;
return [...prev, d];
});
};
const saveDays = async () => {
if (!daysOk(days)) {
setError("Между днями нужен минимум 1 день перерыва");
return;
}
setDaysBusy(true);
setError("");
try {
const next = await api.updateTrainingDays(days);
setProfile(next);
setDays(next.training_weekdays ?? days);
setEditingDays(false);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setDaysBusy(false);
}
};
const exportData = async () => {
setError("");
try {
const data = await api.export();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `benchpress-secret-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
setMessage("Экспорт скачан");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка экспорта");
}
};
const onImport = async (file: File) => {
setError("");
try {
const text = await file.text();
const payload = JSON.parse(text);
const res = await api.import({ version: payload.version ?? 1, data: payload.data ?? payload });
setMessage(res.message);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка импорта");
}
};
const reset = async () => {
if (
!confirm(
"Данные будут удалены и прогресс потерян. После этого снова выберете 1ПМ, дни и дату старта. Продолжить?",
)
) {
return;
}
try {
await api.reset();
await refresh();
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка сброса");
}
};
const daysLabel =
profile?.training_weekdays
?.slice()
.sort((a, b) => a - b)
.map((d) => WEEKDAY_FULL[d])
.join(", ") || "—";
return (
<div className="page settings-page">
<header className="topbar">
<p className="brand">Настройки</p>
</header>
<section className="settings-list">
<div className="summary-box">
<p>
<strong>1ПМ:</strong> {profile?.one_rm_kg ?? "—"} кг
</p>
<p>
<strong>Дни:</strong> {daysLabel}
</p>
<p>
<strong>Старт:</strong> {profile?.start_date ? formatStart(profile.start_date) : "—"}
</p>
</div>
<button className="btn" type="button" onClick={() => setEditingDays((open) => !open)}>
Изменить дни тренировок
</button>
{editingDays && (
<div className="card-form">
<div className="day-grid day-grid-lg">
{WEEKDAY_FULL.map((label, idx) => {
const on = days.includes(idx);
return (
<button
key={label}
type="button"
className={`chip chip-lg ${on ? "on" : ""}`}
onClick={() => toggleDay(idx)}
>
{label}
{on && <span className="chip-check">✓</span>}
</button>
);
})}
</div>
{days.length === 3 && !daysOk(days) && (
<p className="error">Между днями нужен минимум 1 день перерыва</p>
)}
<button className="btn primary" type="button" disabled={!daysOk(days) || daysBusy} onClick={saveDays}>
{daysBusy ? "Сохраняем…" : "Сохранить дни"}
</button>
</div>
)}
<button className="btn" type="button" onClick={exportData}>
Экспорт прогресса
</button>
<button className="btn" type="button" onClick={() => fileRef.current?.click()}>
Импорт прогресса
</button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImport(f);
}}
/>
</section>
{message && <p className="ok">{message}</p>}
{error && <p className="error">{error}</p>}
<div className="settings-foot">
<button className="btn wide" type="button" onClick={() => navigate("/")}>
Назад
</button>
<button className="btn wide danger" type="button" onClick={reset}>
Обнулить тренировки
</button>
</div>
</div>
);
}
+807
View File
@@ -0,0 +1,807 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { api, formatDateRu, formatDhms, formatKg, formatRestCountdown, type Exercise, type WorkoutDetail, type WorkoutSummary } from "../api";
import { AppCalendar } from "../components/AppCalendar";
import { WeightStepper } from "../components/WeightStepper";
const ACCESSORY_STEP = 0.5;
function AccessoryWeightEditor({
ex,
busy,
onSave,
}: {
ex: Exercise;
busy: boolean;
onSave: (weight: number) => void;
}) {
const step = ex.weight_step_kg || ACCESSORY_STEP;
const [value, setValue] = useState(ex.prescribed_weight_kg ?? step * 20);
useEffect(() => {
setValue(ex.prescribed_weight_kg ?? step * 20);
}, [ex.id, ex.prescribed_weight_kg, step]);
return (
<div className="inline-form">
<WeightStepper
compact
label={`Вес «${ex.name}»`}
value={value}
step={step}
min={step}
onChange={setValue}
action={
<button
className="icon-btn check-btn"
type="button"
disabled={busy || value <= 0}
aria-label="Сохранить вес"
onClick={() => onSave(value)}
>
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</button>
}
/>
</div>
);
}
function roundToStep(value: number, step: number, min: number): number {
const next = Math.round(value / step) * step;
return Math.max(min, Math.round(next * 100) / 100);
}
function EditIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
);
}
function AccessorySetRow({
workoutId,
exerciseId,
setId,
weight,
reps,
targetReps,
step,
showAdjust,
onError,
}: {
workoutId: number;
exerciseId: number;
setId: number;
weight: number;
reps: number;
targetReps: number | null;
step: number;
showAdjust: boolean;
onError: (message: string) => void;
}) {
const [rowWeight, setRowWeight] = useState(weight);
const [rowReps, setRowReps] = useState(reps);
const [rowBusy, setRowBusy] = useState(false);
const save = async (nextWeight: number, nextReps: number) => {
const prevWeight = rowWeight;
const prevReps = rowReps;
setRowWeight(nextWeight);
setRowReps(nextReps);
setRowBusy(true);
try {
await api.adjustSet(workoutId, exerciseId, setId, {
actual_weight_kg: nextWeight,
actual_reps: nextReps,
});
} catch (err) {
setRowWeight(prevWeight);
setRowReps(prevReps);
onError(err instanceof Error ? err.message : "Ошибка");
} finally {
setRowBusy(false);
}
};
return (
<li className="done">
<span>
{formatKg(rowWeight)} кг × {targetReps}
</span>
{showAdjust && (
<span className="set-adjust">
<button
type="button"
className="set-step"
disabled={rowBusy}
aria-label="Меньше вес"
onClick={() => save(roundToStep(rowWeight - step, step, step), rowReps)}
>
−
</button>
<span className="set-step-label">кг</span>
<button
type="button"
className="set-step"
disabled={rowBusy}
aria-label="Больше вес"
onClick={() => save(roundToStep(rowWeight + step, step, step), rowReps)}
>
+
</button>
<button
type="button"
className="set-step"
disabled={rowBusy || rowReps <= 0}
aria-label="Меньше повторений"
onClick={() => save(rowWeight, Math.max(0, rowReps - 1))}
>
−
</button>
<span className="set-step-label">повт.</span>
<button
type="button"
className="set-step"
disabled={rowBusy}
aria-label="Больше повторений"
onClick={() => save(rowWeight, rowReps + 1)}
>
+
</button>
</span>
)}
</li>
);
}
function allSetsDone(ex: Exercise): boolean {
return ex.sets.length > 0 && ex.sets.every((s) => s.completed);
}
function doneCount(ex: Exercise): number {
return ex.sets.filter((s) => s.completed).length;
}
export function WorkoutPage() {
const { id } = useParams();
const navigate = useNavigate();
const workoutId = Number(id);
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
const [error, setError] = useState("");
const [reps, setReps] = useState("");
const [, setWeightValue] = useState(10);
const [adjustAmount, setAdjustAmount] = useState(0.5);
const [restSeconds, setRestSeconds] = useState(0);
const [pauseLeftSeconds, setPauseLeftSeconds] = useState(0);
const [untilWorkoutSeconds, setUntilWorkoutSeconds] = useState(0);
const [dateOpen, setDateOpen] = useState(false);
const [schedule, setSchedule] = useState<WorkoutSummary[]>([]);
const [busy, setBusy] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const prevExerciseId = useRef<number | null>(null);
const applyWorkout = (w: WorkoutDetail) => {
setWorkout(w);
setRestSeconds(w.rest_elapsed_seconds || 0);
if (w.status === "paused" && w.pause_expires_at) {
const left = Math.max(0, Math.floor((new Date(w.pause_expires_at).getTime() - Date.now()) / 1000));
setPauseLeftSeconds(left);
} else {
setPauseLeftSeconds(0);
}
const ex = w.exercises[w.current_exercise_index];
if (ex?.kind === "accessory") {
const step = ex.weight_step_kg ?? ACCESSORY_STEP;
if (ex.prescribed_weight_kg != null) {
setWeightValue(ex.prescribed_weight_kg);
} else {
setWeightValue(step * 20); // разумный старт ~10 кг при шаге 0.5
}
setAdjustAmount(step);
}
const next = ex?.sets.find((s) => !s.completed);
if (next?.target_reps != null) setReps(String(next.target_reps));
};
const load = async () => {
const w = await api.workout(workoutId);
applyWorkout(w);
const cur = w.exercises[w.current_exercise_index];
if (cur && (w.status === "in_progress" || w.status === "paused")) {
setExpandedId(cur.id);
prevExerciseId.current = cur.id;
} else {
setExpandedId(null);
prevExerciseId.current = null;
}
};
const pageRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ x: 0, y: 0, horizontal: false, decided: false });
const leavingRef = useRef(false);
const location = useLocation();
const [offset, setOffset] = useState(0);
const [glide, setGlide] = useState(false);
useEffect(() => {
setEditingId(null);
setDateOpen(false);
load().catch((err) => setError(err instanceof Error ? err.message : "Ошибка"));
api.upcoming().then(setSchedule).catch(() => undefined);
}, [workoutId]);
useEffect(() => {
if (!workout || workout.status !== "in_progress" || !workout.rest_started_at) return;
const t = setInterval(() => setRestSeconds((s) => s + 1), 1000);
return () => clearInterval(t);
}, [workout?.status, workout?.rest_started_at, workout?.id, workout?.current_set_index]);
useEffect(() => {
if (!workout || workout.status !== "paused" || !workout.pause_expires_at || !workout.can_resume) return;
const tick = () => {
const left = Math.max(0, Math.floor((new Date(workout.pause_expires_at!).getTime() - Date.now()) / 1000));
setPauseLeftSeconds(left);
};
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [workout?.status, workout?.pause_expires_at, workout?.can_resume, workout?.id]);
useEffect(() => {
if (!workout || workout.status !== "planned") return;
const tick = () => {
const start = new Date(workout.scheduled_date + "T00:00:00").getTime();
setUntilWorkoutSeconds(Math.max(0, Math.floor((start - Date.now()) / 1000)));
};
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [workout?.status, workout?.scheduled_date, workout?.id]);
// Auto-expand current exercise when it changes; collapse the previous one
useEffect(() => {
if (!workout) return;
if (workout.status !== "in_progress" && workout.status !== "paused") return;
const cur = workout.exercises[workout.current_exercise_index];
if (!cur) return;
if (prevExerciseId.current === cur.id) return;
setExpandedId(cur.id);
prevExerciseId.current = cur.id;
}, [workout?.current_exercise_index, workout?.status, workout?.id, workout?.exercises]);
const currentExercise = workout?.exercises[workout.current_exercise_index];
const currentSet = useMemo(() => {
if (!workout || !currentExercise) return null;
return currentExercise.sets.find((s) => s.set_index === workout.current_set_index) || null;
}, [workout, currentExercise]);
const liveStatus = useMemo(() => {
if (!workout) return "";
if (workout.status === "in_progress" && workout.rest_started_at) {
return `Отдых ${formatDhms(restSeconds)}`;
}
if (workout.status === "paused" && workout.can_resume) {
return `Отдых ${formatDhms(pauseLeftSeconds)}`;
}
if (workout.status === "in_progress") {
return "Идёт тренировка";
}
if (workout.status === "planned") {
if (workout.can_start) return "Можно начать";
if (untilWorkoutSeconds > 0) return `Отдых ${formatDhms(untilWorkoutSeconds)}`;
return "Запланирована";
}
if (workout.status === "completed") return "Выполнена";
if (workout.status === "abandoned") return "Остановлена";
if (workout.status === "paused") return "Остановлена";
return "Запланирована";
}, [workout, restSeconds, pauseLeftSeconds, untilWorkoutSeconds]);
const toggleExpanded = (exerciseId: number) => {
setExpandedId((prev) => (prev === exerciseId ? null : exerciseId));
};
const run = async (fn: () => Promise<WorkoutDetail>) => {
setBusy(true);
setError("");
try {
applyWorkout(await fn());
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
await load().catch(() => undefined);
} finally {
setBusy(false);
}
};
const neighbor = useMemo(() => {
const idx = schedule.findIndex((w) => w.id === workout?.id);
return {
prev: idx > 0 ? schedule[idx - 1] : null,
next: idx >= 0 && idx < schedule.length - 1 ? schedule[idx + 1] : null,
};
}, [schedule, workout?.id]);
const neighborRef = useRef(neighbor);
neighborRef.current = neighbor;
const dateOpenRef = useRef(dateOpen);
dateOpenRef.current = dateOpen;
useLayoutEffect(() => {
const slide = (location.state as { slide?: "next" | "prev" } | null)?.slide;
if (!slide) {
setGlide(false);
setOffset(0);
return;
}
setGlide(false);
setOffset(slide === "next" ? window.innerWidth : -window.innerWidth);
const frame = requestAnimationFrame(() => {
setGlide(true);
setOffset(0);
});
return () => cancelAnimationFrame(frame);
}, [workoutId, location.state]);
useEffect(() => {
const el = pageRef.current;
if (!el) return;
const start = (e: globalThis.TouchEvent) => {
if (dateOpenRef.current || leavingRef.current) return;
const t = e.changedTouches[0];
dragRef.current = { x: t.clientX, y: t.clientY, horizontal: false, decided: false };
};
const move = (e: globalThis.TouchEvent) => {
if (dateOpenRef.current || leavingRef.current) return;
const drag = dragRef.current;
if (!drag.x && !drag.y) return;
const t = e.changedTouches[0];
const dx = t.clientX - drag.x;
const dy = t.clientY - drag.y;
if (!drag.decided) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
drag.decided = true;
drag.horizontal = Math.abs(dx) > Math.abs(dy);
}
if (!drag.horizontal) return;
e.preventDefault();
let shift = dx;
if (shift < 0 && !neighborRef.current.next) shift *= 0.22;
if (shift > 0 && !neighborRef.current.prev) shift *= 0.22;
setGlide(false);
setOffset(shift);
};
const end = (e: globalThis.TouchEvent) => {
const drag = dragRef.current;
dragRef.current = { x: 0, y: 0, horizontal: false, decided: false };
if (!drag.horizontal || dateOpenRef.current || leavingRef.current) {
if (drag.horizontal) {
setGlide(true);
setOffset(0);
}
return;
}
const t = e.changedTouches[0];
const dx = t.clientX - drag.x;
const target = dx < 0 ? neighborRef.current.next : neighborRef.current.prev;
if (Math.abs(dx) < 70 || !target) {
setGlide(true);
setOffset(0);
return;
}
leavingRef.current = true;
const dir = dx < 0 ? "next" : "prev";
setGlide(true);
setOffset(dir === "next" ? -window.innerWidth : window.innerWidth);
window.setTimeout(() => {
leavingRef.current = false;
navigate(`/workout/${target.id}`, { state: { slide: dir } });
}, 230);
};
el.addEventListener("touchstart", start, { passive: true });
el.addEventListener("touchmove", move, { passive: false });
el.addEventListener("touchend", end);
return () => {
el.removeEventListener("touchstart", start);
el.removeEventListener("touchmove", move);
el.removeEventListener("touchend", end);
};
}, [workout?.id, navigate]);
if (!workout) {
return (
<div className="page">
<p className="muted">{error || "Загрузка…"}</p>
</div>
);
}
return (
<div
ref={pageRef}
className="page workout-page"
style={{
transform: `translate3d(${offset}px, 0, 0)`,
transition: glide ? "transform 230ms ease" : "none",
}}
>
<header className="topbar">
<div className="topbar-actions">
{workout.status === "in_progress" && (
<button type="button" className="btn ghost" disabled={busy} onClick={() => run(() => api.pause(workout.id))}>
Пауза
</button>
)}
{workout.status === "paused" && workout.can_resume && (
<button type="button" className="btn primary" disabled={busy} onClick={() => run(() => api.resume(workout.id))}>
Продолжить
</button>
)}
</div>
</header>
<div className="workout-meta">
<h1>
Неделя {workout.week_number} · день {workout.day_index + 1}
</h1>
<p className="muted status-line">
{formatDateRu(workout.scheduled_date)}
{!(workout.status === "planned" && !workout.can_start && untilWorkoutSeconds > 0) && ` · ${liveStatus}`}
</p>
{workout.status === "planned" && !workout.can_start && untilWorkoutSeconds > 0 && (
<div className="rest-until">
<p className="rest-title">Отдых</p>
<p>до следующей тренировки {formatRestCountdown(untilWorkoutSeconds)}</p>
</div>
)}
{workout.status === "paused" && !workout.can_resume && (
<p className="error">Пауза истекла — тренировка остановлена</p>
)}
</div>
{workout.status === "planned" && workout.can_start && (
<button className="btn primary wide" disabled={busy} onClick={() => run(() => api.start(workout.id))}>
Начать тренировку
</button>
)}
{dateOpen && workout.status !== "completed" && (
<div className="inline-form" ref={(node) => node?.scrollIntoView({ block: "start" })}>
<p className="move-prompt">Выберите новую дату</p>
<AppCalendar
value={workout.scheduled_date}
workouts={schedule}
onPick={(isoDate) => {
setDateOpen(false);
if (isoDate === workout.scheduled_date) return;
setBusy(true);
setError("");
api
.moveWorkout(workout.id, isoDate)
.then(() => navigate("/"))
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Ошибка");
setBusy(false);
});
}}
/>
</div>
)}
<section className="exercise-list">
<h2>Упражнения</h2>
{workout.exercises.map((ex, index) => {
const isOpen = expandedId === ex.id;
const isCurrent =
(workout.status === "in_progress" || workout.status === "paused") &&
index === workout.current_exercise_index;
const finished = allSetsDone(ex);
const weightKnown = ex.kind === "accessory" && !ex.needs_weight_input && ex.prescribed_weight_kg != null;
const canEdit = ex.kind === "accessory" && (finished || weightKnown);
const editing = editingId === ex.id;
const step = ex.weight_step_kg || ACCESSORY_STEP;
return (
<div
key={ex.id}
className={`ex-block ${isOpen ? "open" : "collapsed"} ${isCurrent ? "current" : ""} ${finished ? "finished" : ""}`}
>
<div className="ex-head">
<button type="button" className="ex-toggle" onClick={() => toggleExpanded(ex.id)}>
<span className="ex-toggle-main">
<span className="chevron" aria-hidden>
{isOpen ? "▾" : "▸"}
</span>
<strong>{ex.name}</strong>
{isCurrent && <span className="badge-now">сейчас</span>}
{finished && !isCurrent && <span className="badge-done">готово</span>}
</span>
<span className="muted ex-toggle-meta">
{doneCount(ex)}/{ex.sets.length}
{ex.kind === "bench"
? " · жим"
: ex.kind === "failure"
? " · отказ"
: ex.prescribed_weight_kg != null
? ` · ${ex.prescribed_weight_kg} кг`
: ""}
</span>
</button>
{canEdit && (
<button
type="button"
className={`ex-edit ${editing ? "on" : ""}`}
aria-label={finished ? "Править выполненные подходы" : "Редактировать вес"}
aria-pressed={editing}
onClick={() => {
setEditingId(editing ? null : ex.id);
setExpandedId(ex.id);
}}
>
<EditIcon />
</button>
)}
</div>
{isOpen && (
<div className="ex-body">
{ex.kind === "accessory" &&
workout.status !== "completed" &&
!finished &&
(ex.needs_weight_input || editing) && (
<AccessoryWeightEditor
ex={ex}
busy={busy}
onSave={(weight) =>
run(async () => {
const next = await api.setWeight(workout.id, ex.id, weight);
setEditingId((current) => (current === ex.id ? null : current));
return next;
})
}
/>
)}
{isCurrent &&
workout.status === "in_progress" &&
currentSet &&
!ex.pending_prompt && (
<div className="active-set nested">
<p className="set-label">
Подход {currentSet.set_index + 1} / {ex.sets.length}
</p>
{ex.kind === "bench" && (
<div className="big-numbers">
<div>
<span className="num">{formatKg(currentSet.target_weight_kg)}</span>
<span className="unit">кг</span>
</div>
<div>
<span className="num">{currentSet.target_reps}</span>
<span className="unit">повт.</span>
</div>
</div>
)}
{ex.kind === "failure" && (
<div className="big-numbers">
<div>
<span className="num">В отказ</span>
</div>
</div>
)}
{ex.kind === "accessory" && !ex.needs_weight_input && (
<>
<div className="big-numbers">
<div>
<span className="num">{formatKg(currentSet.target_weight_kg)}</span>
<span className="unit">кг</span>
</div>
<div>
<span className="num">{currentSet.target_reps}</span>
<span className="unit">цель</span>
</div>
</div>
<label>
Выполнено повторений
<input
type="number"
min="0"
value={reps}
onChange={(e) => setReps(e.target.value)}
/>
</label>
</>
)}
{!ex.needs_weight_input && (
<button
className="btn primary wide"
disabled={busy || (ex.kind === "accessory" && reps === "")}
onClick={() =>
run(() =>
api.completeSet(
workout.id,
ex.kind === "accessory"
? {
actual_reps: Number(reps),
actual_weight_kg: ex.prescribed_weight_kg ?? undefined,
}
: {},
),
)
}
>
Выполнено
</button>
)}
</div>
)}
{isCurrent && workout.status === "in_progress" && ex.pending_prompt && (
<div className="prompt-box nested">
{ex.pending_prompt === "confirm_easy" && (
<>
<h2>Было слишком легко?</h2>
<div className="row">
<button
className="btn"
disabled={busy}
onClick={() =>
run(() => api.prompt(workout.id, ex.id, { choice: "easy_no" }))
}
>
Нет, оставить вес
</button>
<button
className="btn primary"
disabled={busy}
onClick={() =>
run(() => api.prompt(workout.id, ex.id, { choice: "easy_yes" }))
}
>
Да, повысить
</button>
</div>
</>
)}
{ex.pending_prompt === "choose_weight_easy" && (
<>
<h2>На сколько поднять вес?</h2>
<p className="muted">
Сейчас: {formatKg(ex.prescribed_weight_kg)} кг → будет{" "}
{formatKg((ex.prescribed_weight_kg ?? 0) + adjustAmount)} кг
</p>
<WeightStepper
label="Прибавка"
value={adjustAmount}
step={ACCESSORY_STEP}
min={ACCESSORY_STEP}
onChange={setAdjustAmount}
/>
<span className="hint">Шаг 0.5 кг. Новый вес не будет меньше текущего.</span>
<button
className="btn primary"
disabled={busy || adjustAmount <= 0}
onClick={() =>
run(() =>
api.prompt(workout.id, ex.id, {
choice: "easy_yes",
increase_kg: adjustAmount,
}),
)
}
>
Применить к следующим тренировкам
</button>
</>
)}
{ex.pending_prompt === "choose_weight_missed" && (
<>
<h2>На сколько снизить вес?</h2>
<p className="muted">
Сейчас: {formatKg(ex.prescribed_weight_kg)} кг → будет{" "}
{formatKg(Math.max(ACCESSORY_STEP, (ex.prescribed_weight_kg ?? 0) - adjustAmount))}{" "}
кг
</p>
<WeightStepper
label="Снижение"
value={adjustAmount}
step={ACCESSORY_STEP}
min={ACCESSORY_STEP}
onChange={setAdjustAmount}
/>
<span className="hint">Шаг 0.5 кг. Новый вес будет меньше текущего.</span>
<button
className="btn primary"
disabled={busy || adjustAmount <= 0}
onClick={() =>
run(() =>
api.prompt(workout.id, ex.id, {
choice: "easy_yes",
decrease_kg: adjustAmount,
}),
)
}
>
Применить к следующим тренировкам
</button>
</>
)}
</div>
)}
<ul>
{ex.sets.map((s) => {
const showAdjust = editing && finished && s.completed && ex.kind === "accessory";
if (showAdjust) {
return (
<AccessorySetRow
key={s.id}
workoutId={workout.id}
exerciseId={ex.id}
setId={s.id}
weight={s.actual_weight_kg ?? s.target_weight_kg ?? ex.prescribed_weight_kg ?? step}
reps={s.actual_reps ?? s.target_reps ?? 0}
targetReps={s.target_reps}
step={step}
showAdjust
onError={setError}
/>
);
}
return (
<li key={s.id} className={s.completed ? "done" : ""}>
<span>
{s.is_failure ? (
<>В отказ {s.completed ? "✓" : ""}</>
) : ex.kind === "bench" ? (
<>
{formatKg(s.target_weight_kg)} кг × {s.target_reps}
{s.completed ? " ✓" : ""}
</>
) : (
<>
{formatKg(s.target_weight_kg ?? ex.prescribed_weight_kg)} кг × {s.target_reps}
</>
)}
</span>
</li>
);
})}
</ul>
</div>
)}
</div>
);
})}
</section>
{error && <p className="error">{error}</p>}
<div className="workout-foot">
{workout.status !== "completed" && (
<button className="btn wide move" type="button" disabled={busy} onClick={() => setDateOpen((open) => !open)}>
{dateOpen ? "Скрыть календарь" : "Перенести тренировку"}
</button>
)}
<button type="button" className="btn wide" onClick={() => navigate("/")}>
Назад
</button>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_OFFLINE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more