feat: ship Peptide Reconstitution Calculator v1.0.0

Interactive reconstitution calculator with peptide-specific presets.

Core features:
- [prc_calculator] shortcode with instant client-side math
- Concentration (mg/mL), mcg/unit, injection volume, syringe units, doses/vial
- Live presets from PR Core dosing data (graceful fallback to 8 defaults)
- REST API (prc/v1): presets list/detail + server-side calculate
- Transient-cached presets with event-driven invalidation
- Dark mode + responsive design (320px+)
- Full teardown in uninstall.php

12 PHP files (max 241 lines), 1 JS file, 1 CSS file.
CI: PHP lint 8.1-8.3, PHPCS WordPress, 300-line check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-17 14:23:59 +07:00
commit 9aa5309eb4
21 changed files with 2203 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
<?php
/**
* REST API controller for the reconstitution calculator.
*
* What: Exposes presets and calculation endpoints for the frontend JS.
* Who calls it: rest_api_init hook in PRC_Calculator::boot().
* Dependencies: PRC_Preset_Provider, PRC_Math.
*
* @see class-prc-preset-provider.php — Data source for presets.
* @see class-prc-math.php — Calculation engine.
* @see class-prc-shortcode.php — Frontend JS consumes these endpoints.
*/
declare(strict_types=1);
/**
* REST endpoints for the reconstitution calculator.
*
* Namespace: prc/v1
* Endpoints:
* GET /presets — List all available peptide presets.
* GET /presets/{slug} — Get a single preset by slug.
* POST /calculate — Run reconstitution math server-side.
*/
class PRC_Rest_Controller {
/**
* REST namespace.
*/
private const NAMESPACE = 'prc/v1';
/**
* Register all routes.
*
* @return void Side effect: registers REST routes.
*/
public function register_routes(): void {
register_rest_route(
self::NAMESPACE,
'/presets',
[
'methods' => 'GET',
'callback' => [ $this, 'get_presets' ],
'permission_callback' => '__return_true',
]
);
register_rest_route(
self::NAMESPACE,
'/presets/(?P<slug>[a-z0-9-]+)',
[
'methods' => 'GET',
'callback' => [ $this, 'get_preset' ],
'permission_callback' => '__return_true',
'args' => [
'slug' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_title',
'required' => true,
],
],
]
);
register_rest_route(
self::NAMESPACE,
'/calculate',
[
'methods' => 'POST',
'callback' => [ $this, 'calculate' ],
'permission_callback' => '__return_true',
'args' => $this->get_calculate_args(),
]
);
}
/**
* GET /presets — return all available presets.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
public function get_presets( \WP_REST_Request $request ): \WP_REST_Response {
$provider = new PRC_Preset_Provider();
$presets = $provider->get_all_presets();
return new \WP_REST_Response( $presets, 200 );
}
/**
* GET /presets/{slug} — return a single preset.
*
* @param \WP_REST_Request $request Request with 'slug' param.
* @return \WP_REST_Response|\WP_Error
*/
public function get_preset( \WP_REST_Request $request ) {
$slug = $request->get_param( 'slug' );
$provider = new PRC_Preset_Provider();
$presets = $provider->get_all_presets();
foreach ( $presets as $preset ) {
if ( $preset['slug'] === $slug ) {
return new \WP_REST_Response( $preset, 200 );
}
}
return new \WP_Error(
'prc_preset_not_found',
__( 'Preset not found.', 'peptide-reconstitution-calculator' ),
[ 'status' => 404 ]
);
}
/**
* POST /calculate — run reconstitution math.
*
* Accepts vial_mg, water_ml, desired_dose, dose_unit and returns
* concentration, injection volume, syringe units, and doses per vial.
*
* @param \WP_REST_Request $request Request with calculation params.
* @return \WP_REST_Response
*/
public function calculate( \WP_REST_Request $request ): \WP_REST_Response {
$result = PRC_Math::calculate(
(float) $request->get_param( 'vial_mg' ),
(float) $request->get_param( 'water_ml' ),
(float) $request->get_param( 'desired_dose' ),
(string) $request->get_param( 'dose_unit' )
);
return new \WP_REST_Response( $result, 200 );
}
/**
* Define validation args for the /calculate endpoint.
*
* @return array<string, array<string, mixed>>
*/
private function get_calculate_args(): array {
return [
'vial_mg' => [
'type' => 'number',
'required' => true,
'sanitize_callback' => 'floatval',
'validate_callback' => fn( $v ) => is_numeric( $v ) && (float) $v > 0,
],
'water_ml' => [
'type' => 'number',
'required' => true,
'sanitize_callback' => 'floatval',
'validate_callback' => fn( $v ) => is_numeric( $v ) && (float) $v > 0,
],
'desired_dose' => [
'type' => 'number',
'required' => true,
'sanitize_callback' => 'floatval',
'validate_callback' => fn( $v ) => is_numeric( $v ) && (float) $v > 0,
],
'dose_unit' => [
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => fn( $v ) => in_array( $v, [ 'mcg', 'mg' ], true ),
],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Activation handler.
*
* What: Runs on plugin activation — flushes rewrite rules.
* Who calls it: register_activation_hook in bootstrap.
* Dependencies: None.
*/
declare(strict_types=1);
/**
* Handles plugin activation tasks.
*/
class PRC_Activator {
/**
* Run activation tasks.
*
* Flushes rewrite rules so any new pages using the shortcode
* are immediately accessible.
*
* @return void
*/
public static function activate(): void {
flush_rewrite_rules();
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* SPL autoloader for PRC_-prefixed classes.
*
* What: Maps PRC_ class names to kebab-case filenames under includes/.
* Who calls it: Required once in the bootstrap file (peptide-reconstitution-calculator.php).
* Dependencies: None.
*
* @see class-pr-core-autoloader.php in Peptide Repo Core — same pattern.
*/
declare(strict_types=1);
/**
* Autoloads PRC_ classes from includes/ and its immediate subdirectories.
*/
class PRC_Autoloader {
/**
* Register the autoloader with SPL.
*
* @return void
*/
public static function register(): void {
spl_autoload_register( [ __CLASS__, 'autoload' ] );
}
/**
* Autoload callback — resolve PRC_ class name to file path.
*
* Strips PRC_ prefix, converts underscores to hyphens, lowercases,
* prepends "class-prc-", and searches includes/ + subdirs.
*
* @param string $class_name Fully qualified class name.
* @return void
*/
public static function autoload( string $class_name ): void {
if ( 0 !== strpos( $class_name, 'PRC_' ) ) {
return;
}
$relative = substr( $class_name, 4 ); // Strip "PRC_".
$filename = 'class-prc-' . str_replace( '_', '-', strtolower( $relative ) ) . '.php';
$base = PRC_PLUGIN_DIR . 'includes/';
// Check includes/ root first.
if ( file_exists( $base . $filename ) ) {
require_once $base . $filename;
return;
}
// Check immediate subdirectories.
$dirs = glob( $base . '*', GLOB_ONLYDIR );
if ( ! is_array( $dirs ) ) {
return;
}
foreach ( $dirs as $dir ) {
$path = $dir . '/' . $filename;
if ( file_exists( $path ) ) {
require_once $path;
return;
}
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Cache invalidation listener — keeps presets in sync with PR Core.
*
* What: Listens for PR Core dosing row lifecycle hooks and clears
* the preset cache so the calculator picks up fresh data.
* Who calls it: PRC_Calculator::boot() registers hooks on init.
* Dependencies: PRC_Preset_Provider (cache invalidation method).
*
* @see class-prc-preset-provider.php — Cache being invalidated.
* @see PR_Core_Dosing_Repository — Fires the hooks we listen to.
*/
declare(strict_types=1);
/**
* Hooks into PR Core actions to invalidate the presets cache.
*/
class PRC_Cache_Listener {
/**
* Register PR Core hooks for cache invalidation.
*
* Safe to call even when PR Core is not active — the hooks
* simply never fire.
*
* @return void Side effect: registers action hooks.
*/
public static function register(): void {
$callback = [ __CLASS__, 'invalidate' ];
// Dosing row lifecycle — affects calculator presets.
add_action( 'pr_core_after_dosing_row_publish', $callback );
// Peptide lifecycle — new peptides mean new preset candidates.
add_action( 'pr_core_after_peptide_publish', $callback );
}
/**
* Invalidate the preset cache.
*
* @return void Side effect: deletes transient.
*/
public static function invalidate(): void {
( new PRC_Preset_Provider() )->invalidate_cache();
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* Main orchestrator for the Reconstitution Calculator plugin.
*
* What: Boots all subsystems — shortcode, REST API, admin settings.
* Who calls it: plugins_loaded hook in bootstrap.
* Dependencies: PRC_Shortcode, PRC_Rest_Controller, PRC_Preset_Provider.
*
* @see class-prc-shortcode.php — Frontend calculator widget.
* @see class-prc-rest-controller.php — REST endpoints for presets.
* @see class-prc-preset-provider.php — PR Core integration + fallback defaults.
*/
declare(strict_types=1);
/**
* Central boot class — wires hooks for the entire plugin.
*/
class PRC_Calculator {
/**
* Boot the plugin — called on plugins_loaded.
*
* Registers the shortcode, REST routes, and admin notice
* if PR Core is not active.
*
* @return void
*/
public static function boot(): void {
// Frontend shortcode — always available.
$shortcode = new PRC_Shortcode();
add_action( 'init', [ $shortcode, 'register' ] );
// REST API — serves presets to the JS calculator.
add_action( 'rest_api_init', [ new PRC_Rest_Controller(), 'register_routes' ] );
// Cache invalidation — keep presets in sync with PR Core dosing data.
PRC_Cache_Listener::register();
// Admin notice when PR Core is absent.
if ( is_admin() && ! self::is_pr_core_active() ) {
add_action( 'admin_notices', [ __CLASS__, 'render_pr_core_notice' ] );
}
}
/**
* Check whether Peptide Repo Core is active.
*
* Looks for the PR_CORE_VERSION constant which is defined
* in peptide-repo-core.php's bootstrap.
*
* @return bool True if PR Core is loaded.
*/
public static function is_pr_core_active(): bool {
return defined( 'PR_CORE_VERSION' );
}
/**
* Render an admin notice suggesting PR Core installation.
*
* Not a blocker — the calculator works standalone with built-in
* defaults. PR Core just unlocks peptide-specific presets.
*
* @return void Side effect: outputs HTML notice.
*/
public static function render_pr_core_notice(): void {
$screen = get_current_screen();
if ( null === $screen ) {
return;
}
// Only show on plugins page and the calculator's own pages.
$show_on = [ 'plugins', 'settings_page_prc-settings' ];
if ( ! in_array( $screen->id, $show_on, true ) ) {
return;
}
printf(
'<div class="notice notice-info is-dismissible"><p>%s</p></div>',
esc_html__(
'Peptide Reconstitution Calculator: Install Peptide Repo Core to unlock peptide-specific presets with real dosing data. The calculator works without it using built-in defaults.',
'peptide-reconstitution-calculator'
)
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* Deactivation handler.
*
* What: Runs on plugin deactivation — flushes rewrite rules.
* Who calls it: register_deactivation_hook in bootstrap.
* Dependencies: None.
*/
declare(strict_types=1);
/**
* Handles plugin deactivation tasks.
*/
class PRC_Deactivator {
/**
* Run deactivation tasks.
*
* Flushes rewrite rules to remove any routes this plugin registered.
*
* @return void
*/
public static function deactivate(): void {
flush_rewrite_rules();
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* Hardcoded default presets — fallback when PR Core is not active.
*
* What: Provides a curated list of popular peptide presets so the
* calculator is useful out of the box, even without the data layer.
* Who calls it: PRC_Preset_Provider when PR Core is absent or as gap-fill.
* Dependencies: None.
*
* @see class-prc-preset-provider.php — Merges these with live PR Core data.
*/
declare(strict_types=1);
/**
* Static class containing built-in peptide presets.
*/
class PRC_Default_Presets {
/**
* Get all built-in presets.
*
* Each preset includes common vial sizes, recommended reconstitution
* volume, typical dose ranges, and frequency. Data sourced from
* published literature and commonly available vendor specifications.
*
* @return array<int, array<string, mixed>>
*/
public static function get_all(): array {
return [
[
'slug' => 'bpc-157',
'name' => 'BPC-157',
'vial_sizes_mg' => [ 5, 10 ],
'default_vial_mg' => 5,
'recommended_water_ml' => 2.0,
'dose_range_min' => 200,
'dose_range_max' => 500,
'dose_unit' => 'mcg',
'typical_frequency' => 'twice daily',
'evidence_strength' => 'observational',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'tb-500',
'name' => 'TB-500',
'vial_sizes_mg' => [ 2, 5, 10 ],
'default_vial_mg' => 5,
'recommended_water_ml' => 2.0,
'dose_range_min' => 2000,
'dose_range_max' => 5000,
'dose_unit' => 'mcg',
'typical_frequency' => 'twice weekly',
'evidence_strength' => 'preclinical',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'semaglutide',
'name' => 'Semaglutide',
'vial_sizes_mg' => [ 2, 5, 10 ],
'default_vial_mg' => 5,
'recommended_water_ml' => 2.0,
'dose_range_min' => 0.25,
'dose_range_max' => 2.4,
'dose_unit' => 'mg',
'typical_frequency' => 'weekly',
'evidence_strength' => 'meta-analysis',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'cjc-1295-dac',
'name' => 'CJC-1295 (DAC)',
'vial_sizes_mg' => [ 2, 5 ],
'default_vial_mg' => 2,
'recommended_water_ml' => 1.0,
'dose_range_min' => 1000,
'dose_range_max' => 2000,
'dose_unit' => 'mcg',
'typical_frequency' => 'weekly',
'evidence_strength' => 'preclinical',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'ipamorelin',
'name' => 'Ipamorelin',
'vial_sizes_mg' => [ 2, 5 ],
'default_vial_mg' => 5,
'recommended_water_ml' => 2.0,
'dose_range_min' => 100,
'dose_range_max' => 300,
'dose_unit' => 'mcg',
'typical_frequency' => 'three times daily',
'evidence_strength' => 'preclinical',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'pt-141',
'name' => 'PT-141 (Bremelanotide)',
'vial_sizes_mg' => [ 2, 10 ],
'default_vial_mg' => 10,
'recommended_water_ml' => 2.0,
'dose_range_min' => 500,
'dose_range_max' => 2000,
'dose_unit' => 'mcg',
'typical_frequency' => 'as needed',
'evidence_strength' => 'rct-large',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'mk-677',
'name' => 'MK-677 (Ibutamoren)',
'vial_sizes_mg' => [ 15, 30 ],
'default_vial_mg' => 30,
'recommended_water_ml' => 3.0,
'dose_range_min' => 10,
'dose_range_max' => 25,
'dose_unit' => 'mg',
'typical_frequency' => 'daily',
'evidence_strength' => 'rct-small',
'dosing_row_count' => 0,
'source' => 'default',
],
[
'slug' => 'ghrp-6',
'name' => 'GHRP-6',
'vial_sizes_mg' => [ 5, 10 ],
'default_vial_mg' => 5,
'recommended_water_ml' => 2.0,
'dose_range_min' => 100,
'dose_range_max' => 300,
'dose_unit' => 'mcg',
'typical_frequency' => 'three times daily',
'evidence_strength' => 'preclinical',
'dosing_row_count' => 0,
'source' => 'default',
],
];
}
}

146
includes/class-prc-math.php Normal file
View File

@@ -0,0 +1,146 @@
<?php
/**
* Reconstitution math engine.
*
* What: Pure calculation functions — no side effects, no database access.
* Computes concentration, injection volume, syringe units, and
* doses per vial from user inputs.
* Who calls it: PRC_Rest_Controller (server-side), also mirrored in JS.
* Dependencies: None.
*
* @see assets/js/calculator.js — Client-side mirror of this math.
* @see api/class-prc-rest-controller.php — Server-side consumer.
*/
declare(strict_types=1);
/**
* Static math functions for reconstitution calculations.
*
* All methods are pure — same inputs always produce same outputs.
* The JS calculator mirrors this logic client-side for instant feedback;
* the REST endpoint provides server-side validation.
*/
class PRC_Math {
/**
* Standard insulin syringe size in units (100 IU = 1 mL).
*/
private const SYRINGE_UNITS_PER_ML = 100;
/**
* Run the full reconstitution calculation.
*
* @param float $vial_mg Amount of peptide in the vial (mg).
* @param float $water_ml Bacteriostatic water added (mL).
* @param float $desired_dose Desired dose per injection.
* @param string $dose_unit Unit of desired_dose: 'mcg' or 'mg'.
* @return array<string, mixed> Calculation results.
*/
public static function calculate(
float $vial_mg,
float $water_ml,
float $desired_dose,
string $dose_unit
): array {
// Normalize desired dose to mg for consistent math.
$dose_mg = ( 'mcg' === $dose_unit )
? $desired_dose / 1000
: $desired_dose;
$concentration_mg_per_ml = self::concentration_mg_per_ml( $vial_mg, $water_ml );
$concentration_mcg_per_unit = self::concentration_mcg_per_unit( $vial_mg, $water_ml );
$injection_ml = self::injection_volume_ml( $dose_mg, $concentration_mg_per_ml );
$syringe_units = self::syringe_units( $injection_ml );
$doses_per_vial = self::doses_per_vial( $vial_mg, $dose_mg );
return [
'concentration_mg_per_ml' => round( $concentration_mg_per_ml, 4 ),
'concentration_mcg_per_unit' => round( $concentration_mcg_per_unit, 2 ),
'injection_volume_ml' => round( $injection_ml, 4 ),
'syringe_units' => round( $syringe_units, 1 ),
'doses_per_vial' => floor( $doses_per_vial ),
'total_peptide_mg' => $vial_mg,
'water_ml' => $water_ml,
'desired_dose_mg' => round( $dose_mg, 4 ),
'desired_dose_display' => $desired_dose,
'dose_unit' => $dose_unit,
];
}
/**
* Concentration after reconstitution in mg/mL.
*
* @param float $vial_mg Peptide amount (mg).
* @param float $water_ml Water volume (mL).
* @return float mg per mL.
*/
public static function concentration_mg_per_ml( float $vial_mg, float $water_ml ): float {
if ( $water_ml <= 0 ) {
return 0;
}
return $vial_mg / $water_ml;
}
/**
* Concentration in mcg per insulin syringe unit.
*
* Why: Most users measure with insulin syringes (100 units = 1 mL).
* Knowing mcg-per-unit makes it easy to count tick marks.
*
* @param float $vial_mg Peptide amount (mg).
* @param float $water_ml Water volume (mL).
* @return float mcg per syringe unit.
*/
public static function concentration_mcg_per_unit( float $vial_mg, float $water_ml ): float {
if ( $water_ml <= 0 ) {
return 0;
}
$total_mcg = $vial_mg * 1000;
$total_units = $water_ml * self::SYRINGE_UNITS_PER_ML;
return $total_mcg / $total_units;
}
/**
* Volume to inject for a desired dose (mL).
*
* @param float $dose_mg Desired dose in mg.
* @param float $concentration_mg_ml Solution concentration in mg/mL.
* @return float Injection volume in mL.
*/
public static function injection_volume_ml( float $dose_mg, float $concentration_mg_ml ): float {
if ( $concentration_mg_ml <= 0 ) {
return 0;
}
return $dose_mg / $concentration_mg_ml;
}
/**
* Convert mL to insulin syringe units.
*
* @param float $ml Volume in mL.
* @return float Syringe units (100 units = 1 mL).
*/
public static function syringe_units( float $ml ): float {
return $ml * self::SYRINGE_UNITS_PER_ML;
}
/**
* How many full doses fit in one vial.
*
* @param float $vial_mg Total peptide in vial (mg).
* @param float $dose_mg Dose per injection (mg).
* @return float Number of doses (floor to get whole doses).
*/
public static function doses_per_vial( float $vial_mg, float $dose_mg ): float {
if ( $dose_mg <= 0 ) {
return 0;
}
return $vial_mg / $dose_mg;
}
}

View File

@@ -0,0 +1,241 @@
<?php
/**
* Preset provider — bridges PR Core dosing data to the calculator.
*
* What: Builds peptide-specific reconstitution presets from PR Core's
* repository classes. Falls back to built-in defaults when PR Core
* is not active.
* Who calls it: PRC_Rest_Controller serves these to the JS calculator.
* Dependencies: PR Core repositories (optional), PRC_Calculator::is_pr_core_active().
*
* @see class-prc-rest-controller.php — Serves presets via REST.
* @see class-prc-default-presets.php — Hardcoded fallback presets.
* @see PR_Core_Peptide_Repository (PR Core) — Peptide list source.
* @see PR_Core_Dosing_Repository (PR Core) — Dosing data source.
*/
declare(strict_types=1);
/**
* Assembles reconstitution presets — live data from PR Core or static defaults.
*/
class PRC_Preset_Provider {
/**
* Cache key prefix for transient storage.
*/
private const CACHE_PREFIX = 'prc_presets_';
/**
* How long to cache presets (seconds). 1 hour.
*/
private const CACHE_TTL_SECONDS = 3600;
/**
* Get all available presets for the calculator dropdown.
*
* Returns a merged list: PR Core live data (if active) + built-in defaults
* for peptides not yet in the database.
*
* @return array<int, array<string, mixed>> Array of preset objects.
*/
public function get_all_presets(): array {
if ( ! PRC_Calculator::is_pr_core_active() ) {
return PRC_Default_Presets::get_all();
}
$cached = get_transient( self::CACHE_PREFIX . 'all' );
if ( false !== $cached && is_array( $cached ) ) {
return $cached;
}
$presets = $this->build_presets_from_pr_core();
// Merge any default presets for peptides not covered by PR Core.
$live_slugs = array_column( $presets, 'slug' );
foreach ( PRC_Default_Presets::get_all() as $default ) {
if ( ! in_array( $default['slug'], $live_slugs, true ) ) {
$presets[] = $default;
}
}
set_transient( self::CACHE_PREFIX . 'all', $presets, self::CACHE_TTL_SECONDS );
return $presets;
}
/**
* Invalidate the presets cache.
*
* Called when PR Core dosing data changes so the calculator
* picks up updated values.
*
* @return void Side effect: deletes transient.
*/
public function invalidate_cache(): void {
delete_transient( self::CACHE_PREFIX . 'all' );
}
/**
* Build preset entries from PR Core's live peptide + dosing data.
*
* For each published peptide with subcutaneous dosing rows, extracts
* common vial sizes, dose range, and typical reconstitution volume.
*
* @return array<int, array<string, mixed>>
*/
private function build_presets_from_pr_core(): array {
$peptide_repo = new PR_Core_Peptide_Repository();
$dosing_repo = new PR_Core_Dosing_Repository();
$peptides = $peptide_repo->find_all( [ 'posts_per_page' => 100 ] );
$presets = [];
foreach ( $peptides as $peptide ) {
$rows = $dosing_repo->find_by_peptide(
$peptide->id,
[ 'route' => 'subcutaneous' ]
);
if ( empty( $rows ) ) {
// No subcutaneous dosing data — skip this peptide.
continue;
}
$preset = $this->build_single_preset( $peptide, $rows );
if ( null !== $preset ) {
$presets[] = $preset;
}
}
return $presets;
}
/**
* Build a single preset from a peptide DTO and its dosing rows.
*
* @param object $peptide PR_Core_Peptide_DTO.
* @param PR_Core_Dosing_Row_DTO[]|null $rows Active subcutaneous dosing rows.
* @return array<string, mixed>|null Null if data is insufficient.
*/
private function build_single_preset( object $peptide, ?array $rows ): ?array {
if ( empty( $rows ) ) {
return null;
}
// Extract dose range across all rows.
$dose_mins = array_filter( array_map( fn( $r ) => $r->dose_min, $rows ) );
$dose_maxs = array_filter( array_map( fn( $r ) => $r->dose_max, $rows ) );
$dose_unit = $rows[0]->dose_unit ?: 'mcg';
// Derive typical vial sizes from dose range.
// Why: Most peptides are sold in standard vial sizes (2mg, 5mg, 10mg, 15mg, 30mg).
$vial_sizes = $this->infer_vial_sizes( $dose_mins, $dose_maxs, $dose_unit );
$display_name = '';
if ( method_exists( $peptide, 'to_array' ) ) {
$arr = $peptide->to_array();
$display_name = $arr['display_name'] ?? $peptide->title ?? '';
} else {
$display_name = $peptide->title ?? '';
}
return [
'slug' => $peptide->slug,
'name' => $display_name ?: $peptide->title,
'vial_sizes_mg' => $vial_sizes,
'default_vial_mg' => $vial_sizes[0] ?? 5,
'recommended_water_ml' => $this->recommend_water_ml( $vial_sizes[0] ?? 5 ),
'dose_range_min' => ! empty( $dose_mins ) ? min( $dose_mins ) : null,
'dose_range_max' => ! empty( $dose_maxs ) ? max( $dose_maxs ) : null,
'dose_unit' => $dose_unit,
'typical_frequency' => $this->most_common_frequency( $rows ),
'evidence_strength' => $peptide->evidence_strength ?? 'preclinical',
'dosing_row_count' => count( $rows ),
'source' => 'pr_core',
];
}
/**
* Infer standard vial sizes from the dose range.
*
* Why: Peptide vendors use standard sizes. If a peptide's typical dose
* is 250mcg, it's usually sold in 5mg or 10mg vials. This heuristic
* picks the 2-3 most likely sizes.
*
* @param float[] $dose_mins Minimum doses from all rows.
* @param float[] $dose_maxs Maximum doses from all rows.
* @param string $dose_unit Unit (mcg or mg).
* @return float[] Standard vial sizes in mg.
*/
private function infer_vial_sizes( array $dose_mins, array $dose_maxs, string $dose_unit ): array {
$standard_sizes = [ 2, 5, 10, 15, 30 ];
if ( empty( $dose_mins ) && empty( $dose_maxs ) ) {
return [ 5, 10 ];
}
// Convert everything to mg for comparison.
$max_dose_mg = 0;
$all_doses = array_merge( $dose_mins, $dose_maxs );
foreach ( $all_doses as $dose ) {
$mg = ( 'mcg' === $dose_unit ) ? $dose / 1000 : $dose;
$max_dose_mg = max( $max_dose_mg, $mg );
}
// Pick vials that hold at least 10 doses at the max dose level.
$viable = [];
foreach ( $standard_sizes as $size ) {
if ( $max_dose_mg > 0 && ( $size / $max_dose_mg ) >= 5 ) {
$viable[] = $size;
}
}
// Fallback: return two middle sizes if heuristic found nothing.
return ! empty( $viable ) ? array_slice( $viable, 0, 3 ) : [ 5, 10 ];
}
/**
* Recommend reconstitution water volume based on vial size.
*
* Why: Standard practice is 1-2mL per 5mg, producing round concentrations.
*
* @param float $vial_mg Vial size in mg.
* @return float Recommended bacteriostatic water in mL.
*/
private function recommend_water_ml( float $vial_mg ): float {
if ( $vial_mg <= 2 ) {
return 1.0;
}
if ( $vial_mg <= 5 ) {
return 2.0;
}
if ( $vial_mg <= 10 ) {
return 2.0;
}
if ( $vial_mg <= 15 ) {
return 3.0;
}
return 5.0;
}
/**
* Find the most common frequency value across dosing rows.
*
* @param PR_Core_Dosing_Row_DTO[] $rows Dosing rows.
* @return string|null Most common frequency, or null if none.
*/
private function most_common_frequency( array $rows ): ?string {
$freqs = array_filter( array_map( fn( $r ) => $r->frequency, $rows ) );
if ( empty( $freqs ) ) {
return null;
}
$counts = array_count_values( $freqs );
arsort( $counts );
return array_key_first( $counts );
}
}

View File

@@ -0,0 +1,241 @@
<?php
/**
* Shortcode registration and frontend asset enqueueing.
*
* What: Registers [prc_calculator] shortcode, enqueues JS/CSS,
* and renders the calculator HTML shell.
* Who calls it: PRC_Calculator::boot() registers this on init.
* Dependencies: PRC_Preset_Provider (for inline preset data).
*
* @see assets/js/calculator.js — Client-side calculator logic.
* @see assets/css/calculator.css — Calculator styles.
* @see class-prc-calculator.php — Boot orchestrator.
*/
declare(strict_types=1);
/**
* Handles the [prc_calculator] shortcode.
*/
class PRC_Shortcode {
/**
* Whether assets have been enqueued this request.
*
* Prevents double-enqueue if shortcode appears multiple times.
*
* @var bool
*/
private bool $assets_enqueued = false;
/**
* Register the shortcode with WordPress.
*
* @return void Side effect: registers shortcode.
*/
public function register(): void {
add_shortcode( 'prc_calculator', [ $this, 'render' ] );
}
/**
* Render the calculator widget.
*
* Outputs the HTML structure and enqueues JS/CSS on first call.
* The JS calculator takes over from here — all interactivity
* is client-side for instant feedback.
*
* @param array<string, string>|string $atts Shortcode attributes.
* @return string HTML output.
*/
public function render( $atts = [] ): string {
$atts = shortcode_atts(
[
'peptide' => '', // Pre-select a peptide by slug.
],
$atts,
'prc_calculator'
);
$this->enqueue_assets();
$selected_slug = sanitize_title( $atts['peptide'] );
ob_start();
$this->render_calculator_html( $selected_slug );
return ob_get_clean();
}
/**
* Enqueue calculator JS and CSS, with inline preset data.
*
* Why inline presets: Avoids a REST round-trip on page load.
* The presets are small (< 5KB) and cached by the provider.
*
* @return void Side effect: enqueues scripts/styles.
*/
private function enqueue_assets(): void {
if ( $this->assets_enqueued ) {
return;
}
wp_enqueue_style(
'prc-calculator',
PRC_PLUGIN_URL . 'assets/css/calculator.css',
[],
PRC_VERSION
);
wp_enqueue_script(
'prc-calculator',
PRC_PLUGIN_URL . 'assets/js/calculator.js',
[],
PRC_VERSION,
true
);
// Pass presets and config to JS.
$provider = new PRC_Preset_Provider();
wp_localize_script( 'prc-calculator', 'prcConfig', [
'presets' => $provider->get_all_presets(),
'restUrl' => esc_url_raw( rest_url( 'prc/v1' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'syringeUnits' => 100,
'i18n' => [
'selectPeptide' => __( 'Select a peptide…', 'peptide-reconstitution-calculator' ),
'customEntry' => __( 'Custom / Manual Entry', 'peptide-reconstitution-calculator' ),
'calculate' => __( 'Calculate', 'peptide-reconstitution-calculator' ),
'reset' => __( 'Reset', 'peptide-reconstitution-calculator' ),
'concentration' => __( 'Concentration', 'peptide-reconstitution-calculator' ),
'injectionVol' => __( 'Injection Volume', 'peptide-reconstitution-calculator' ),
'syringeUnits' => __( 'Syringe Units', 'peptide-reconstitution-calculator' ),
'dosesPerVial' => __( 'Doses Per Vial', 'peptide-reconstitution-calculator' ),
'perUnit' => __( 'per unit', 'peptide-reconstitution-calculator' ),
'disclaimer' => __( 'For informational purposes only. Not medical advice. Always consult a qualified healthcare professional.', 'peptide-reconstitution-calculator' ),
],
] );
$this->assets_enqueued = true;
}
/**
* Output the calculator HTML shell.
*
* The JS calculator populates the interactive elements.
* This provides the semantic structure and no-JS fallback message.
*
* @param string $selected_slug Pre-selected peptide slug (empty = none).
* @return void Side effect: outputs HTML.
*/
private function render_calculator_html( string $selected_slug ): void {
?>
<div class="prc-calculator" data-selected-slug="<?php echo esc_attr( $selected_slug ); ?>">
<div class="prc-calculator__header">
<h3 class="prc-calculator__title">
<?php esc_html_e( 'Peptide Reconstitution Calculator', 'peptide-reconstitution-calculator' ); ?>
</h3>
</div>
<div class="prc-calculator__body">
<!-- Peptide selector -->
<div class="prc-field">
<label class="prc-field__label" for="prc-peptide-select">
<?php esc_html_e( 'Peptide', 'peptide-reconstitution-calculator' ); ?>
</label>
<select id="prc-peptide-select" class="prc-field__input">
<option value=""><?php esc_html_e( 'Select a peptide…', 'peptide-reconstitution-calculator' ); ?></option>
</select>
</div>
<!-- Vial size -->
<div class="prc-field">
<label class="prc-field__label" for="prc-vial-mg">
<?php esc_html_e( 'Vial Size', 'peptide-reconstitution-calculator' ); ?>
</label>
<div class="prc-field__row">
<select id="prc-vial-select" class="prc-field__input prc-field__input--half"></select>
<div class="prc-field__custom-wrap">
<input type="number" id="prc-vial-mg" class="prc-field__input prc-field__input--half"
step="0.1" min="0.1" placeholder="5" />
<span class="prc-field__unit"><?php esc_html_e( 'mg', 'peptide-reconstitution-calculator' ); ?></span>
</div>
</div>
</div>
<!-- Bacteriostatic water -->
<div class="prc-field">
<label class="prc-field__label" for="prc-water-ml">
<?php esc_html_e( 'Bacteriostatic Water', 'peptide-reconstitution-calculator' ); ?>
</label>
<div class="prc-field__row">
<input type="number" id="prc-water-ml" class="prc-field__input"
step="0.1" min="0.1" placeholder="2.0" />
<span class="prc-field__unit"><?php esc_html_e( 'mL', 'peptide-reconstitution-calculator' ); ?></span>
</div>
</div>
<!-- Desired dose -->
<div class="prc-field">
<label class="prc-field__label" for="prc-desired-dose">
<?php esc_html_e( 'Desired Dose', 'peptide-reconstitution-calculator' ); ?>
</label>
<div class="prc-field__row">
<input type="number" id="prc-desired-dose" class="prc-field__input"
step="any" min="0" placeholder="250" />
<select id="prc-dose-unit" class="prc-field__input prc-field__input--unit">
<option value="mcg">mcg</option>
<option value="mg">mg</option>
</select>
</div>
<div id="prc-dose-range" class="prc-field__hint"></div>
</div>
<!-- Frequency (informational) -->
<div id="prc-frequency-row" class="prc-field prc-field--hidden">
<label class="prc-field__label">
<?php esc_html_e( 'Typical Frequency', 'peptide-reconstitution-calculator' ); ?>
</label>
<span id="prc-frequency-value" class="prc-field__static"></span>
</div>
</div>
<!-- Results panel -->
<div id="prc-results" class="prc-results prc-results--hidden">
<h4 class="prc-results__title">
<?php esc_html_e( 'Results', 'peptide-reconstitution-calculator' ); ?>
</h4>
<div class="prc-results__grid">
<div class="prc-result-card">
<div class="prc-result-card__value" id="prc-result-concentration">—</div>
<div class="prc-result-card__label"><?php esc_html_e( 'Concentration (mg/mL)', 'peptide-reconstitution-calculator' ); ?></div>
</div>
<div class="prc-result-card">
<div class="prc-result-card__value" id="prc-result-mcg-per-unit">—</div>
<div class="prc-result-card__label"><?php esc_html_e( 'mcg per Syringe Unit', 'peptide-reconstitution-calculator' ); ?></div>
</div>
<div class="prc-result-card prc-result-card--primary">
<div class="prc-result-card__value" id="prc-result-syringe-units">—</div>
<div class="prc-result-card__label"><?php esc_html_e( 'Syringe Units to Inject', 'peptide-reconstitution-calculator' ); ?></div>
</div>
<div class="prc-result-card">
<div class="prc-result-card__value" id="prc-result-injection-ml">—</div>
<div class="prc-result-card__label"><?php esc_html_e( 'Injection Volume (mL)', 'peptide-reconstitution-calculator' ); ?></div>
</div>
<div class="prc-result-card">
<div class="prc-result-card__value" id="prc-result-doses">—</div>
<div class="prc-result-card__label"><?php esc_html_e( 'Doses Per Vial', 'peptide-reconstitution-calculator' ); ?></div>
</div>
</div>
</div>
<!-- Disclaimer -->
<div class="prc-disclaimer" id="prc-disclaimer"></div>
<noscript>
<p class="prc-noscript">
<?php esc_html_e( 'This calculator requires JavaScript to function.', 'peptide-reconstitution-calculator' ); ?>
</p>
</noscript>
</div>
<?php
}
}