@jjlmoya/utils-sports 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/category/i18n/en.ts +108 -0
- package/src/category/i18n/es.ts +108 -0
- package/src/category/i18n/fr.ts +95 -0
- package/src/category/index.ts +21 -0
- package/src/category/seo.astro +15 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +11 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +55 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +146 -0
- package/src/pages/[locale].astro +251 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/faq_count.test.ts +19 -0
- package/src/tests/locale_completeness.test.ts +42 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/seo_length.test.ts +22 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_validation.test.ts +17 -0
- package/src/tool/gymTracker/bibliography.astro +15 -0
- package/src/tool/gymTracker/component.astro +835 -0
- package/src/tool/gymTracker/exercises.ts +28 -0
- package/src/tool/gymTracker/i18n/en.ts +225 -0
- package/src/tool/gymTracker/i18n/es.ts +225 -0
- package/src/tool/gymTracker/i18n/fr.ts +225 -0
- package/src/tool/gymTracker/index.ts +34 -0
- package/src/tool/gymTracker/logic.ts +169 -0
- package/src/tool/gymTracker/seo.astro +15 -0
- package/src/tool/gymTracker/storage.ts +43 -0
- package/src/tool/gymTracker/timer.ts +126 -0
- package/src/tool/gymTracker/types.ts +11 -0
- package/src/tool/gymTracker/ui-utils.ts +59 -0
- package/src/tool/gymTracker/ui.ts +27 -0
- package/src/tool/reactionTester/bibliography.astro +2 -0
- package/src/tool/reactionTester/component.astro +1074 -0
- package/src/tool/reactionTester/i18n/en.ts +144 -0
- package/src/tool/reactionTester/i18n/es.ts +144 -0
- package/src/tool/reactionTester/i18n/fr.ts +144 -0
- package/src/tool/reactionTester/index.ts +34 -0
- package/src/tool/reactionTester/seo.astro +12 -0
- package/src/tool/reactionTester/ui.ts +43 -0
- package/src/tool/scoreKeeper/bibliography.astro +14 -0
- package/src/tool/scoreKeeper/component.astro +858 -0
- package/src/tool/scoreKeeper/i18n/en.ts +207 -0
- package/src/tool/scoreKeeper/i18n/es.ts +207 -0
- package/src/tool/scoreKeeper/i18n/fr.ts +207 -0
- package/src/tool/scoreKeeper/index.ts +35 -0
- package/src/tool/scoreKeeper/logic.ts +275 -0
- package/src/tool/scoreKeeper/seo.astro +15 -0
- package/src/tool/scoreKeeper/sports.ts +70 -0
- package/src/tool/scoreKeeper/ui.ts +19 -0
- package/src/tool/tournamentBracket/bibliography.astro +10 -0
- package/src/tool/tournamentBracket/component.astro +1092 -0
- package/src/tool/tournamentBracket/i18n/en.ts +160 -0
- package/src/tool/tournamentBracket/i18n/es.ts +178 -0
- package/src/tool/tournamentBracket/i18n/fr.ts +160 -0
- package/src/tool/tournamentBracket/index.ts +34 -0
- package/src/tool/tournamentBracket/logic/active.controller.ts +106 -0
- package/src/tool/tournamentBracket/logic/generator.ts +71 -0
- package/src/tool/tournamentBracket/logic/manager.ts +165 -0
- package/src/tool/tournamentBracket/logic/setup.controller.ts +84 -0
- package/src/tool/tournamentBracket/logic/sharing.ts +81 -0
- package/src/tool/tournamentBracket/logic/storage.ts +56 -0
- package/src/tool/tournamentBracket/models.ts +34 -0
- package/src/tool/tournamentBracket/seo.astro +12 -0
- package/src/tool/tournamentBracket/tournament.controller.ts +65 -0
- package/src/tool/tournamentBracket/tournament.renderer.ts +45 -0
- package/src/tool/tournamentBracket/ui/bracket-desktop.ts +143 -0
- package/src/tool/tournamentBracket/ui/bracket-mobile.ts +82 -0
- package/src/tool/tournamentBracket/ui/mediator.ts +96 -0
- package/src/tool/tournamentBracket/ui/navigator.ts +84 -0
- package/src/tool/tournamentBracket/ui/setup.ts +120 -0
- package/src/tool/tournamentBracket/ui.ts +42 -0
- package/src/tools.ts +13 -0
- package/src/types.ts +72 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { GymTrackerUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
const slug = 'suivi-entrainement-gym';
|
|
6
|
+
const title = 'Suivi d’Entraînement Gym : Graphiques de Progrès et Force';
|
|
7
|
+
const description =
|
|
8
|
+
'Enregistrez vos levées, sélectionnez l’exercice et visualisez votre progression avec des graphiques de force. Optimisez votre surcharge progressive à la salle.';
|
|
9
|
+
|
|
10
|
+
const faqData = [
|
|
11
|
+
{
|
|
12
|
+
question: 'À quoi sert le suivi d’entraînement ?',
|
|
13
|
+
answer:
|
|
14
|
+
'Il sert à appliquer la surcharge progressive de manière scientifique. En sachant exactement ce que vous avez soulevé lors de la séance précédente, vous pouvez essayer de battre cette marque, ce qui garantit la croissance musculaire et les gains de force à long terme.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
question: 'Quelles données dois-je enregistrer ?',
|
|
18
|
+
answer:
|
|
19
|
+
'Le plus important est le poids maximum (top set) que vous avez réussi à déplacer avec une bonne technique pour un nombre déterminé de répétitions. Notre outil se concentre sur l’enregistrement du poids par séance pour générer votre graphique de progrès.',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
question: 'Comment interpréter les graphiques ?',
|
|
23
|
+
answer:
|
|
24
|
+
'Une ligne ascendante indique que vous progressez. Une ligne plate (stagnation) suggère que vous devez ajuster votre volume, votre intensité ou votre récupération. Une ligne descendante persistante peut être le signe d’un surentraînement.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
question: 'Où sont stockées mes données ?',
|
|
28
|
+
answer:
|
|
29
|
+
'Les données sont stockées localement dans votre navigateur (Local Storage). Cela signifie que votre vie privée est totale et que vous n’avez pas besoin de créer de compte, mais si vous effacez les données du navigateur, l’historique sera perdu.',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const howToData = [
|
|
34
|
+
{
|
|
35
|
+
name: 'Sélectionnez l’exercice',
|
|
36
|
+
text: 'Choisissez parmi les mouvements fondamentaux comme le Squat, le Développé Couché ou le Soulevé de Terre dans le menu déroulant.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'Entrez le poids',
|
|
40
|
+
text: 'Après votre série la plus lourde, entrez les kilogrammes soulevés dans le champ correspondant.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'Appuyez sur Ajouter',
|
|
44
|
+
text: 'Enregistrez votre marque. Le système mettra automatiquement à jour votre historique et votre graphique de progression.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'Analysez votre évolution',
|
|
48
|
+
text: 'Consultez le graphique périodiquement pour identifier les stagnations et vous motiver en voyant votre croissance réelle en force.',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
53
|
+
'@context': 'https://schema.org',
|
|
54
|
+
'@type': 'FAQPage',
|
|
55
|
+
mainEntity: faqData.map((item) => ({
|
|
56
|
+
'@type': 'Question',
|
|
57
|
+
name: item.question,
|
|
58
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const howToSchema: WithContext<HowTo> = {
|
|
63
|
+
'@context': 'https://schema.org',
|
|
64
|
+
'@type': 'HowTo',
|
|
65
|
+
name: title,
|
|
66
|
+
description,
|
|
67
|
+
step: howToData.map((step, i) => ({
|
|
68
|
+
'@type': 'HowToStep',
|
|
69
|
+
position: i + 1,
|
|
70
|
+
name: step.name,
|
|
71
|
+
text: step.text,
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
76
|
+
'@context': 'https://schema.org',
|
|
77
|
+
'@type': 'SoftwareApplication',
|
|
78
|
+
name: title,
|
|
79
|
+
description,
|
|
80
|
+
applicationCategory: 'HealthApplication',
|
|
81
|
+
operatingSystem: 'All',
|
|
82
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
|
83
|
+
inLanguage: 'fr',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const content: ToolLocaleContent<GymTrackerUI & Record<string, string>> = {
|
|
87
|
+
slug,
|
|
88
|
+
title,
|
|
89
|
+
description,
|
|
90
|
+
faqTitle: 'Questions Fréquentes',
|
|
91
|
+
faq: faqData,
|
|
92
|
+
bibliographyTitle: 'Références Scientifiques',
|
|
93
|
+
bibliography: [
|
|
94
|
+
{
|
|
95
|
+
name: 'Journal of Strength and Conditioning Research - Progressive Overload Study',
|
|
96
|
+
url: 'https://journals.lww.com/nsca-jscr/Fulltext/2010/10000/The_Mechanisms_of_Muscle_Hypertrophy_and_Their.40.aspx',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'National Academy of Sports Medicine - Progressive Overload Explained',
|
|
100
|
+
url: 'https://blog.nasm.org/progressive-overload-explained',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'Science of Strength - Data Tracking in Resistance Training',
|
|
104
|
+
url: 'https://pubmed.ncbi.nlm.nih.gov/30558493/',
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
howTo: howToData,
|
|
108
|
+
schemas: [faqSchema, howToSchema, appSchema],
|
|
109
|
+
seo: [
|
|
110
|
+
{
|
|
111
|
+
type: 'title',
|
|
112
|
+
text: 'Suivi d’Entraînement à la Salle : La Clé d’une Progression Réelle',
|
|
113
|
+
level: 2,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'paragraph',
|
|
117
|
+
html: 'Dans le monde du fitness et du bodybuilding, il existe un principe fondamental qui sépare ceux qui obtiennent des résultats étonnants de ceux qui stagnent rapidement : la <strong>surcharge progressive</strong>. Cependant, il est impossible d’appliquer ce principe efficacement si vous ne tenez pas un registre détaillé de vos levées. Dans ce guide, nous explorerons pourquoi le suivi de votre entraînement est vital, comment utiliser notre <strong>suivi d’entraînement gym</strong> pour maximiser vos gains, et les fondements scientifiques qui soutiennent cette pratique.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: 'title',
|
|
121
|
+
text: 'Qu’est-ce que la Surcharge Progressive ?',
|
|
122
|
+
level: 3,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
type: 'paragraph',
|
|
126
|
+
html: 'La surcharge progressive est l’augmentation graduelle du stress appliqué au corps pendant l’exercice physique. Pour qu’un muscle se développe ou se renforce, il doit être soumis à un stimulus plus important que celui auquel il est habitué. Si vous allez à la salle et que vous soulevez toujours le même poids, avec les mêmes répétitions et le même temps de repos, votre corps n’aura aucune raison biologique de s’adapter et de se développer.',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: 'list',
|
|
130
|
+
items: [
|
|
131
|
+
'Augmentation du poids : Soulever plus lourd que la séance précédente.',
|
|
132
|
+
'Augmentation des répétitions : Faire plus de répétitions avec le même poids.',
|
|
133
|
+
'Augmentation du volume : Réaliser plus de séries totales par groupe musculaire.',
|
|
134
|
+
'Réduction du repos : Faire le même effort en moins de temps.',
|
|
135
|
+
'Amélioration de la technique : Réaliser l’exercice avec un contrôle accru et une plus grande amplitude de mouvement.',
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
type: 'title',
|
|
140
|
+
text: 'Pourquoi le Journal de Bord Manuel est Supérieur à la Mémoire',
|
|
141
|
+
level: 3,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: 'paragraph',
|
|
145
|
+
html: 'De nombreux athlètes font l’erreur de confier à leur mémoire le soin de se rappeler ce qu’ils ont soulevé la semaine dernière. Pourtant, dans un entraînement typique comprenant 5 à 10 exercices différents, il est très facile d’oublier si vous avez fait 80 kg o 82.5 kg à la presse, ou si vous avez réussi 10 répétitions ou 12. Ce manque de précision mène à la médiocrité.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: 'tip',
|
|
149
|
+
title: 'Le Pouvoir de Visualiser le Progrès',
|
|
150
|
+
html: 'Voir une ligne ascendante sur un graphique vous donne l’élan nécessaire pour tenter cette répétition supplémentaire qui fait la différence entre la stagnation et une croissance musculaire constante.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: 'title',
|
|
154
|
+
text: 'Les Exercices Fondamentaux pour le Suivi',
|
|
155
|
+
level: 3,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: 'paragraph',
|
|
159
|
+
html: 'Bien que tous les exercices soient précieux, certains mouvements polyarticulaires offrent la meilleure vision de votre force globale et de votre développement physique. Voici ceux que vous devriez prioriser dans votre suivi : <strong>Développé Couché</strong> pour la poussée horizontale, <strong>Développé Militaire</strong> pour la poussée verticale, <strong>Tractions</strong> pour le tirage et <strong>Hip Thrust</strong> pour les fessiers.',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: 'title',
|
|
163
|
+
text: 'Comment Analyser vos Graphiques de Progression',
|
|
164
|
+
level: 3,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
type: 'paragraph',
|
|
168
|
+
html: 'Une fois que vous aurez enregistré plusieurs séances, vous commencerez à voir des modèles : une <strong>ligne ascendante constante</strong> indique que vous êtes sur la bonne voie, une <strong>ligne plate</strong> suggère que vous devez ajuster votre volume ou votre repos, et une <strong>tendance à la baisse</strong> peut être un signe de fatigue accumulée.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: 'title',
|
|
172
|
+
text: 'La Psychologie du Succès à la Salle',
|
|
173
|
+
level: 3,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
type: 'paragraph',
|
|
177
|
+
html: 'S’entraîner est autant un défi mental que physique. En utilisant un outil visuel qui vous montre qu’aujourd’hui vous êtes 1% plus fort qu’il y a quinze jours, vous alimentez votre système de récompense par la dopamine. Cela crée une boucle de rétroaction positive qui transforme l’entraînement en une habitude durable.',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
ui: {
|
|
181
|
+
exerciseLabel: 'Exercice',
|
|
182
|
+
pushCategory: 'Poussée',
|
|
183
|
+
pullCategory: 'Tirage',
|
|
184
|
+
gluteCategory: 'Fessiers',
|
|
185
|
+
customExerciseCategory: 'Personnalisés',
|
|
186
|
+
addCustomExerciseTitle: 'Ajouter un exercice personnalisé',
|
|
187
|
+
newExerciseLabel: 'Nouvel exercice',
|
|
188
|
+
exercisePlaceholder: 'Nom de l’exercice...',
|
|
189
|
+
addBtn: 'Ajouter',
|
|
190
|
+
registerWeightLabel: 'Enregistrer le Poids',
|
|
191
|
+
weightPlaceholder: '00.0',
|
|
192
|
+
startBtn: 'Démarrer',
|
|
193
|
+
resetBtn: 'Réinitialiser',
|
|
194
|
+
okBtn: 'OK',
|
|
195
|
+
noData: 'Pas de données',
|
|
196
|
+
recordLabel: 'Record',
|
|
197
|
+
lastLabel: 'Dernier',
|
|
198
|
+
historyTitle: 'Logs',
|
|
199
|
+
exportBtn: 'Exporter',
|
|
200
|
+
confirmDeleteTitle: 'Effacer l’historique ?',
|
|
201
|
+
confirmDeleteText: 'Cette action ne peut pas être annulée. Tous les enregistrements de l’exercice sélectionné seront supprimés.',
|
|
202
|
+
deleteBtn: 'Supprimer',
|
|
203
|
+
cancelBtn: 'Annuler',
|
|
204
|
+
units: 'kg',
|
|
205
|
+
|
|
206
|
+
benchPress: 'Développé Couché',
|
|
207
|
+
overheadPress: 'Développé Militaire',
|
|
208
|
+
pushPress: 'Push Press',
|
|
209
|
+
inclineDbPress: 'Développé Incliné Haltères',
|
|
210
|
+
dipsTriceps: 'Dips Triceps',
|
|
211
|
+
tricepsExtensions: 'Extensions Triceps Poulie',
|
|
212
|
+
pullUp: 'Tractions',
|
|
213
|
+
barbellRow: 'Tirage Buste Penché',
|
|
214
|
+
latPulldown: 'Tirage Vertical',
|
|
215
|
+
dbRow: 'Tirage Haltère',
|
|
216
|
+
facePulls: 'Face Pulls',
|
|
217
|
+
bicepsCurl: 'Curl Biceps Barre',
|
|
218
|
+
hipThrust: 'Hip Thrust',
|
|
219
|
+
rdl: 'Soulevé de Terre Roumain',
|
|
220
|
+
lunges: 'Fentes',
|
|
221
|
+
gluteKick: 'Kickback Fessier Poulie',
|
|
222
|
+
hipAbduction: 'Abduction Hanche Machine',
|
|
223
|
+
stepUp: 'Step Up',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SportsToolEntry, ToolLocaleContent, ToolDefinition } from '../../types';
|
|
2
|
+
import GymTrackerComponent from './component.astro';
|
|
3
|
+
import GymTrackerSEO from './seo.astro';
|
|
4
|
+
import GymTrackerBibliography from './bibliography.astro';
|
|
5
|
+
import type { GymTrackerUI } from './ui';
|
|
6
|
+
|
|
7
|
+
export type { GymTrackerUI };
|
|
8
|
+
export type GymTrackerLocaleContent = ToolLocaleContent<GymTrackerUI>;
|
|
9
|
+
|
|
10
|
+
import { content as es } from './i18n/es';
|
|
11
|
+
import { content as en } from './i18n/en';
|
|
12
|
+
import { content as fr } from './i18n/fr';
|
|
13
|
+
|
|
14
|
+
export const gymTracker: SportsToolEntry<GymTrackerUI> = {
|
|
15
|
+
id: 'gym-tracker',
|
|
16
|
+
icons: {
|
|
17
|
+
bg: 'mdi:weight-lifter',
|
|
18
|
+
fg: 'mdi:chart-line',
|
|
19
|
+
},
|
|
20
|
+
i18n: {
|
|
21
|
+
es: async () => es as ToolLocaleContent<GymTrackerUI>,
|
|
22
|
+
en: async () => en as ToolLocaleContent<GymTrackerUI>,
|
|
23
|
+
fr: async () => fr as ToolLocaleContent<GymTrackerUI>,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export { GymTrackerComponent, GymTrackerSEO, GymTrackerBibliography };
|
|
28
|
+
|
|
29
|
+
export const GYM_TRACKER_TOOL: ToolDefinition = {
|
|
30
|
+
entry: gymTracker,
|
|
31
|
+
Component: GymTrackerComponent,
|
|
32
|
+
SEOComponent: GymTrackerSEO,
|
|
33
|
+
BibliographyComponent: GymTrackerBibliography,
|
|
34
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { getHistory, saveLog, deleteLog, getCustomExercises, saveCustomExercise, deleteExerciseHistory } from "./storage";
|
|
2
|
+
import { initTimer } from "./timer";
|
|
3
|
+
import { updateExerciseOptions, renderHistoryTable, drawProgressChart } from "./ui-utils";
|
|
4
|
+
import type { CustomExercise, HistoryData } from "./types";
|
|
5
|
+
|
|
6
|
+
interface GTElements {
|
|
7
|
+
exerciseSelect: HTMLSelectElement;
|
|
8
|
+
weightInput: HTMLInputElement;
|
|
9
|
+
addLogBtn: HTMLElement | null;
|
|
10
|
+
clearHistoryBtn: HTMLElement | null;
|
|
11
|
+
customExerciseInput: HTMLInputElement;
|
|
12
|
+
addCustomExerciseBtn: HTMLElement | null;
|
|
13
|
+
toggleFormBtn: HTMLElement | null;
|
|
14
|
+
closeFormBtn: HTMLElement | null;
|
|
15
|
+
form: HTMLElement | null;
|
|
16
|
+
exportBtn: HTMLElement | null;
|
|
17
|
+
exportMenu: HTMLElement | null;
|
|
18
|
+
jsonBtn: HTMLElement | null;
|
|
19
|
+
csvBtn: HTMLElement | null;
|
|
20
|
+
modal: HTMLElement | null;
|
|
21
|
+
confirmBtn: HTMLElement | null;
|
|
22
|
+
cancelBtn: HTMLElement | null;
|
|
23
|
+
list: HTMLElement | null;
|
|
24
|
+
maxEl: HTMLElement | null;
|
|
25
|
+
lastEl: HTMLElement | null;
|
|
26
|
+
empty: HTMLElement | null;
|
|
27
|
+
chartPath: SVGPathElement | null;
|
|
28
|
+
chartPoints: HTMLElement | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function initGymTracker() {
|
|
32
|
+
const container = document.getElementById('gym-tracker-app');
|
|
33
|
+
if (!container) return;
|
|
34
|
+
const ui = JSON.parse(container.dataset.gtUi || '{}') as Record<string, string>;
|
|
35
|
+
const els = getElements();
|
|
36
|
+
if (!els.exerciseSelect) return;
|
|
37
|
+
setupEventListeners(els, ui);
|
|
38
|
+
initTimer();
|
|
39
|
+
getCustomExercises().forEach(ex => addExerciseToSelect(els.exerciseSelect, ex.id, ex.name, ui));
|
|
40
|
+
updateUI(els, ui);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getElements(): GTElements {
|
|
44
|
+
return {
|
|
45
|
+
exerciseSelect: document.getElementById('exerciseSelect') as HTMLSelectElement,
|
|
46
|
+
weightInput: document.getElementById('weightInput') as HTMLInputElement,
|
|
47
|
+
addLogBtn: document.getElementById('addLogBtn'),
|
|
48
|
+
clearHistoryBtn: document.getElementById('clearHistoryBtn'),
|
|
49
|
+
customExerciseInput: document.getElementById('customExerciseInput') as HTMLInputElement,
|
|
50
|
+
addCustomExerciseBtn: document.getElementById('addCustomExerciseBtn'),
|
|
51
|
+
toggleFormBtn: document.getElementById('toggleCustomExerciseBtn'),
|
|
52
|
+
closeFormBtn: document.getElementById('closeCustomExerciseBtn'),
|
|
53
|
+
form: document.getElementById('customExerciseForm'),
|
|
54
|
+
exportBtn: document.getElementById('exportDataBtn'),
|
|
55
|
+
exportMenu: document.getElementById('exportMenu'),
|
|
56
|
+
jsonBtn: document.getElementById('exportJsonBtn'),
|
|
57
|
+
csvBtn: document.getElementById('exportCsvBtn'),
|
|
58
|
+
modal: document.getElementById('confirmModal'),
|
|
59
|
+
confirmBtn: document.getElementById('confirmDeleteBtn'),
|
|
60
|
+
cancelBtn: document.getElementById('cancelDeleteBtn'),
|
|
61
|
+
list: document.getElementById('historyList'),
|
|
62
|
+
maxEl: document.getElementById('maxWeight'),
|
|
63
|
+
lastEl: document.getElementById('lastWeight'),
|
|
64
|
+
empty: document.getElementById('emptyState'),
|
|
65
|
+
chartPath: document.getElementById('chartPath') as unknown as SVGPathElement | null,
|
|
66
|
+
chartPoints: document.getElementById('chartPoints'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setupEventListeners(els: GTElements, ui: Record<string, string>) {
|
|
71
|
+
els.addLogBtn?.addEventListener('click', () => {
|
|
72
|
+
const w = parseFloat(els.weightInput.value);
|
|
73
|
+
if (!isNaN(w) && w > 0) {
|
|
74
|
+
saveLog(els.exerciseSelect.value, w);
|
|
75
|
+
els.weightInput.value = '';
|
|
76
|
+
updateUI(els, ui);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
els.toggleFormBtn?.addEventListener('click', () => {
|
|
80
|
+
els.form?.classList.toggle('gt-hidden');
|
|
81
|
+
if (!els.form?.classList.contains('gt-hidden')) els.customExerciseInput?.focus();
|
|
82
|
+
});
|
|
83
|
+
els.closeFormBtn?.addEventListener('click', () => els.form?.classList.add('gt-hidden'));
|
|
84
|
+
els.addCustomExerciseBtn?.addEventListener('click', () => {
|
|
85
|
+
const name = els.customExerciseInput.value.trim();
|
|
86
|
+
if (name) {
|
|
87
|
+
const id = saveCustomExercise(name);
|
|
88
|
+
addExerciseToSelect(els.exerciseSelect, id, name, ui);
|
|
89
|
+
els.exerciseSelect.value = id;
|
|
90
|
+
els.customExerciseInput.value = '';
|
|
91
|
+
els.form?.classList.add('gt-hidden');
|
|
92
|
+
updateUI(els, ui);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
setupExportAndModals(els, ui);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setupExportAndModals(els: GTElements, ui: Record<string, string>) {
|
|
99
|
+
els.exportBtn?.addEventListener('click', (e: Event) => {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
els.exportMenu?.classList.toggle('gt-hidden');
|
|
102
|
+
});
|
|
103
|
+
els.jsonBtn?.addEventListener('click', () => {
|
|
104
|
+
exportJSON(getHistory(), getCustomExercises());
|
|
105
|
+
els.exportMenu?.classList.add('gt-hidden');
|
|
106
|
+
});
|
|
107
|
+
els.csvBtn?.addEventListener('click', () => {
|
|
108
|
+
exportCSV(getHistory());
|
|
109
|
+
els.exportMenu?.classList.add('gt-hidden');
|
|
110
|
+
});
|
|
111
|
+
els.clearHistoryBtn?.addEventListener('click', () => els.modal?.classList.remove('gt-hidden'));
|
|
112
|
+
els.confirmBtn?.addEventListener('click', () => {
|
|
113
|
+
deleteExerciseHistory(els.exerciseSelect.value);
|
|
114
|
+
els.modal?.classList.add('gt-hidden');
|
|
115
|
+
updateUI(els, ui);
|
|
116
|
+
});
|
|
117
|
+
els.cancelBtn?.addEventListener('click', () => els.modal?.classList.add('gt-hidden'));
|
|
118
|
+
els.exerciseSelect?.addEventListener('change', () => updateUI(els, ui));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function addExerciseToSelect(select: HTMLSelectElement, id: string, name: string, ui: Record<string, string>) {
|
|
122
|
+
let group = select.querySelector('optgroup[data-group="custom"]') as HTMLOptGroupElement;
|
|
123
|
+
if (!group) {
|
|
124
|
+
group = document.createElement('optgroup');
|
|
125
|
+
group.label = ui.customExerciseCategory;
|
|
126
|
+
group.dataset.group = "custom";
|
|
127
|
+
select.appendChild(group);
|
|
128
|
+
}
|
|
129
|
+
const opt = document.createElement('option');
|
|
130
|
+
opt.value = id;
|
|
131
|
+
opt.textContent = name;
|
|
132
|
+
group.appendChild(opt);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function updateUI(els: GTElements, ui: Record<string, string>) {
|
|
136
|
+
const history = getHistory();
|
|
137
|
+
const logs = history[els.exerciseSelect.value] || [];
|
|
138
|
+
updateExerciseOptions(els.exerciseSelect, history, ui);
|
|
139
|
+
if (els.maxEl) els.maxEl.textContent = logs.length ? `${Math.max(...logs.map(l => l.weight))} ${ui.units}` : `0 ${ui.units}`;
|
|
140
|
+
if (els.lastEl) els.lastEl.textContent = logs.length ? `${logs[logs.length-1].weight} ${ui.units}` : `0 ${ui.units}`;
|
|
141
|
+
if (els.empty) els.empty.classList.toggle('gt-hidden', logs.length > 0);
|
|
142
|
+
if (els.list) renderHistoryTable(els.list, logs, ui, (idx) => {
|
|
143
|
+
deleteLog(els.exerciseSelect.value, idx);
|
|
144
|
+
updateUI(els, ui);
|
|
145
|
+
});
|
|
146
|
+
drawProgressChart(els.chartPath, els.chartPoints, logs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function exportJSON(history: HistoryData, customExercises: CustomExercise[]) {
|
|
150
|
+
downloadFile(JSON.stringify({ exportDate: new Date().toISOString(), history, customExercises }, null, 2), `gym-tracker-${new Date().toISOString().split('T')[0]}.json`, 'application/json');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function exportCSV(history: HistoryData) {
|
|
154
|
+
const rows = ['Exercise,Date,Weight'];
|
|
155
|
+
Object.entries(history).forEach(([id, logs]) => {
|
|
156
|
+
logs.forEach(l => rows.push(`"${id}",${new Date(l.date).toLocaleDateString()},${l.weight}`));
|
|
157
|
+
});
|
|
158
|
+
downloadFile(rows.join('\n'), `gym-tracker-${new Date().toISOString().split('T')[0]}.csv`, 'text/csv');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function downloadFile(content: string, filename: string, type: string) {
|
|
162
|
+
const blob = new Blob([content], { type });
|
|
163
|
+
const url = URL.createObjectURL(blob);
|
|
164
|
+
const a = document.createElement('a');
|
|
165
|
+
a.href = url;
|
|
166
|
+
a.download = filename;
|
|
167
|
+
a.click();
|
|
168
|
+
URL.revokeObjectURL(url);
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { gymTracker } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'es' } = Astro.props;
|
|
11
|
+
const content = await gymTracker.i18n[locale]?.();
|
|
12
|
+
if (!content) return;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<SEORenderer content={{ locale: locale as string, sections: content.seo }} />
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { HistoryData, CustomExercise } from "./types";
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya-gym-tracker-data';
|
|
4
|
+
const CUSTOM_EXERCISES_KEY = 'jjlmoya-gym-tracker-custom';
|
|
5
|
+
|
|
6
|
+
export function getHistory(): HistoryData {
|
|
7
|
+
const data = localStorage.getItem(STORAGE_KEY);
|
|
8
|
+
return data ? JSON.parse(data) : {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function saveLog(exerciseId: string, weight: number) {
|
|
12
|
+
const history = getHistory();
|
|
13
|
+
if (!history[exerciseId]) history[exerciseId] = [];
|
|
14
|
+
history[exerciseId].push({ date: new Date().toISOString(), weight });
|
|
15
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function deleteLog(exerciseId: string, index: number) {
|
|
19
|
+
const history = getHistory();
|
|
20
|
+
if (history[exerciseId]) {
|
|
21
|
+
history[exerciseId].splice(index, 1);
|
|
22
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function deleteExerciseHistory(exerciseId: string) {
|
|
27
|
+
const history = getHistory();
|
|
28
|
+
delete history[exerciseId];
|
|
29
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getCustomExercises(): CustomExercise[] {
|
|
33
|
+
const data = localStorage.getItem(CUSTOM_EXERCISES_KEY);
|
|
34
|
+
return data ? JSON.parse(data) : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function saveCustomExercise(name: string): string {
|
|
38
|
+
const custom = getCustomExercises();
|
|
39
|
+
const id = `custom-${Date.now()}`;
|
|
40
|
+
custom.push({ id, name });
|
|
41
|
+
localStorage.setItem(CUSTOM_EXERCISES_KEY, JSON.stringify(custom));
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const TIMER_STORAGE_KEY = 'jjlmoya-gym-tracker-data-timer';
|
|
2
|
+
|
|
3
|
+
interface TimerState {
|
|
4
|
+
interval: number | null;
|
|
5
|
+
timeLeft: number;
|
|
6
|
+
initialTime: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TimerElements {
|
|
10
|
+
display: HTMLElement | null;
|
|
11
|
+
startBtn: HTMLElement | null;
|
|
12
|
+
resetBtn: HTMLElement | null;
|
|
13
|
+
progress: HTMLElement | null;
|
|
14
|
+
customInput: HTMLInputElement | null;
|
|
15
|
+
setCustomBtn: HTMLElement | null;
|
|
16
|
+
presets: NodeListOf<Element>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function initTimer() {
|
|
20
|
+
const els = getTimerElements();
|
|
21
|
+
if (!els.display) return;
|
|
22
|
+
|
|
23
|
+
const state: TimerState = {
|
|
24
|
+
interval: null,
|
|
25
|
+
timeLeft: 0,
|
|
26
|
+
initialTime: 0
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
setupTimerEvents(els, state);
|
|
30
|
+
restoreTimerState(els, state);
|
|
31
|
+
updateTimerUI(els, state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getTimerElements(): TimerElements {
|
|
35
|
+
return {
|
|
36
|
+
display: document.getElementById('timerDisplay'),
|
|
37
|
+
startBtn: document.getElementById('startTimerBtn'),
|
|
38
|
+
resetBtn: document.getElementById('resetTimerBtn'),
|
|
39
|
+
progress: document.getElementById('timerProgress'),
|
|
40
|
+
customInput: document.getElementById('customTimerInput') as HTMLInputElement | null,
|
|
41
|
+
setCustomBtn: document.getElementById('setCustomTimerBtn'),
|
|
42
|
+
presets: document.querySelectorAll('.preset-btn')
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setupTimerEvents(els: TimerElements, state: TimerState) {
|
|
47
|
+
els.startBtn?.addEventListener('click', () => {
|
|
48
|
+
if (state.timeLeft > 0 && !state.interval) startTimer(els, state, state.timeLeft);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
els.resetBtn?.addEventListener('click', () => {
|
|
52
|
+
stopTimer(els, state);
|
|
53
|
+
state.timeLeft = 0;
|
|
54
|
+
state.initialTime = 0;
|
|
55
|
+
updateTimerUI(els, state);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
els.presets.forEach((btn: Element) => {
|
|
59
|
+
(btn as HTMLElement).onclick = () => startTimer(els, state, parseInt((btn as HTMLElement).dataset.seconds || '0'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (els.setCustomBtn) {
|
|
63
|
+
els.setCustomBtn.onclick = () => {
|
|
64
|
+
const s = parseInt(els.customInput?.value || '0');
|
|
65
|
+
if (!isNaN(s) && s > 0) startTimer(els, state, s);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startTimer(els: TimerElements, state: TimerState, seconds: number) {
|
|
71
|
+
stopTimer(els, state);
|
|
72
|
+
state.timeLeft = seconds;
|
|
73
|
+
state.initialTime = seconds;
|
|
74
|
+
els.display?.classList.add('gt-timer-active');
|
|
75
|
+
|
|
76
|
+
state.interval = window.setInterval(() => {
|
|
77
|
+
state.timeLeft--;
|
|
78
|
+
if (state.timeLeft <= 0) {
|
|
79
|
+
state.timeLeft = 0;
|
|
80
|
+
stopTimer(els, state);
|
|
81
|
+
} else {
|
|
82
|
+
saveTimerState(state);
|
|
83
|
+
}
|
|
84
|
+
updateTimerUI(els, state);
|
|
85
|
+
}, 1000);
|
|
86
|
+
|
|
87
|
+
saveTimerState(state);
|
|
88
|
+
updateTimerUI(els, state);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stopTimer(els: TimerElements, state: TimerState) {
|
|
92
|
+
if (state.interval) clearInterval(state.interval);
|
|
93
|
+
state.interval = null;
|
|
94
|
+
localStorage.removeItem(TIMER_STORAGE_KEY);
|
|
95
|
+
els.display?.classList.remove('gt-timer-active');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function updateTimerUI(els: TimerElements, state: TimerState) {
|
|
99
|
+
if (!els.display) return;
|
|
100
|
+
const m = Math.floor(state.timeLeft / 60);
|
|
101
|
+
const s = state.timeLeft % 60;
|
|
102
|
+
els.display.textContent = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
103
|
+
if (els.progress && state.initialTime > 0) {
|
|
104
|
+
els.progress.style.width = `${(state.timeLeft / state.initialTime) * 100}%`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function saveTimerState(state: TimerState) {
|
|
109
|
+
if (state.interval && state.timeLeft > 0) {
|
|
110
|
+
const endTime = Date.now() + (state.timeLeft * 1000);
|
|
111
|
+
localStorage.setItem(TIMER_STORAGE_KEY, JSON.stringify({ endTime, initialTime: state.initialTime }));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function restoreTimerState(els: TimerElements, state: TimerState) {
|
|
116
|
+
const data = localStorage.getItem(TIMER_STORAGE_KEY);
|
|
117
|
+
if (!data) return;
|
|
118
|
+
const { endTime, initialTime } = JSON.parse(data);
|
|
119
|
+
const remaining = Math.round((endTime - Date.now()) / 1000);
|
|
120
|
+
if (remaining > 0) {
|
|
121
|
+
state.initialTime = initialTime;
|
|
122
|
+
startTimer(els, state, remaining);
|
|
123
|
+
} else {
|
|
124
|
+
localStorage.removeItem(TIMER_STORAGE_KEY);
|
|
125
|
+
}
|
|
126
|
+
}
|