95 lines
3.3 KiB
JavaScript
95 lines
3.3 KiB
JavaScript
// Shared helpers: API access, DOM building, formatting
|
||
|
||
import { accessToken, redirectToLogin, refresh } from './auth.js';
|
||
|
||
const API = '/api/v1';
|
||
|
||
/** What a training without a trainer is called */
|
||
export const SOLO = 'соло';
|
||
|
||
async function send(path, options, token) {
|
||
const headers = { ...options.headers, Authorization: `Bearer ${token}` };
|
||
return fetch(API + path, { ...options, headers });
|
||
}
|
||
|
||
/** JSON request to the API with the access token. Without a session the browser is sent to
|
||
* the login page; an expired token is refreshed and the request repeated once. */
|
||
export async function api(path, options = {}) {
|
||
const token = await accessToken();
|
||
if (!token) {
|
||
return redirectToLogin();
|
||
}
|
||
let response = await send(path, options, token);
|
||
if (response.status === 401) {
|
||
if (!(await refresh())) {
|
||
return redirectToLogin();
|
||
}
|
||
response = await send(path, options, await accessToken());
|
||
}
|
||
if (!response.ok) {
|
||
const error = new Error(`HTTP ${response.status}`);
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
/** h('div', {class: 'x'}, 'text', childNode) -> element; strings become text nodes */
|
||
export function h(tag, attrs = {}, ...children) {
|
||
const element = document.createElement(tag);
|
||
for (const [name, value] of Object.entries(attrs)) {
|
||
if (value !== null && value !== undefined && value !== false) {
|
||
element.setAttribute(name, value === true ? '' : value);
|
||
}
|
||
}
|
||
element.append(...children.filter((child) => child !== null && child !== undefined));
|
||
return element;
|
||
}
|
||
|
||
export function alert(kind, ...children) {
|
||
return h('div', { class: `alert alert-${kind}` }, ...children);
|
||
}
|
||
|
||
/** "2026-10-21" -> "21.10.2026" (no Date: a timezone must not shift the day) */
|
||
export function formatDate(iso) {
|
||
const [year, month, day] = iso.split('-');
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
/** "First Last" of a user, the email when the name is empty. Trainers in a list come with
|
||
* a ready `name`. */
|
||
export function userName(user) {
|
||
return user.name || `${user.first_name} ${user.last_name}`.trim() || user.email;
|
||
}
|
||
|
||
/** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */
|
||
export function formatDateTime(iso) {
|
||
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }).replace(',', '');
|
||
}
|
||
|
||
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
||
export function num(value) {
|
||
return String(Math.round(value * 10) / 10);
|
||
}
|
||
|
||
/** Same with an explicit sign: "+12", "-3.5", "+0" */
|
||
export function signed(value) {
|
||
const rounded = Math.round(value * 10) / 10;
|
||
return (rounded > 0 ? '+' : '') + rounded;
|
||
}
|
||
|
||
/** Approaches as "weight × reps" chips */
|
||
export function approachChips(approaches) {
|
||
return approaches.map((approach) =>
|
||
h('span', { class: 'approach' },
|
||
`${approach.weight ? num(approach.weight) + ' кг' : 'без веса'} × ${approach.reps}`)
|
||
);
|
||
}
|
||
|
||
export function perArmBadge() {
|
||
return h('span', { class: 'badge text-bg-light border', title: 'Вес указан на каждую руку (гантель)' }, 'на руку');
|
||
}
|
||
|
||
export function tip(text, hint) {
|
||
return h('span', { class: 'has-tip', title: hint }, text);
|
||
}
|