initial commit
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,2 @@
|
||||
# Offline V2: данные на устройстве (IndexedDB), сервер не нужен.
|
||||
VITE_OFFLINE=true
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: "Прогресс удалён. Пройдите онбординг заново." };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||