From 9aa5309eb4b24b86e6cc3df3d3433fe80c7a8301 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 17 Apr 2026 14:23:59 +0700 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 70 +++++ .gitignore | 7 + ARCHITECTURE.md | 128 +++++++++ CHANGELOG.md | 21 ++ CONVENTIONS.md | 48 ++++ assets/css/calculator.css | 303 ++++++++++++++++++++ assets/js/calculator.js | 309 +++++++++++++++++++++ composer.json | 24 ++ includes/api/class-prc-rest-controller.php | 167 +++++++++++ includes/class-prc-activator.php | 28 ++ includes/class-prc-autoloader.php | 67 +++++ includes/class-prc-cache-listener.php | 47 ++++ includes/class-prc-calculator.php | 86 ++++++ includes/class-prc-deactivator.php | 27 ++ includes/class-prc-default-presets.php | 145 ++++++++++ includes/class-prc-math.php | 146 ++++++++++ includes/class-prc-preset-provider.php | 241 ++++++++++++++++ includes/frontend/class-prc-shortcode.php | 241 ++++++++++++++++ peptide-reconstitution-calculator.php | 45 +++ phpcs.xml.dist | 18 ++ uninstall.php | 35 +++ 21 files changed, 2203 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 CONVENTIONS.md create mode 100644 assets/css/calculator.css create mode 100644 assets/js/calculator.js create mode 100644 composer.json create mode 100644 includes/api/class-prc-rest-controller.php create mode 100644 includes/class-prc-activator.php create mode 100644 includes/class-prc-autoloader.php create mode 100644 includes/class-prc-cache-listener.php create mode 100644 includes/class-prc-calculator.php create mode 100644 includes/class-prc-deactivator.php create mode 100644 includes/class-prc-default-presets.php create mode 100644 includes/class-prc-math.php create mode 100644 includes/class-prc-preset-provider.php create mode 100644 includes/frontend/class-prc-shortcode.php create mode 100644 peptide-reconstitution-calculator.php create mode 100644 phpcs.xml.dist create mode 100644 uninstall.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa063e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + # ── PHP Syntax Check ───────────────────────────────────────────── + lint-php: + name: PHP Lint (${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + matrix: + php: [ '8.1', '8.2', '8.3' ] + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Syntax check all PHP files + run: find . -name "*.php" -not -path "./vendor/*" | xargs -n 1 php -l + + # ── WordPress Coding Standards (PHP CodeSniffer) ────────────────── + lint-phpcs: + name: WordPress Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Run PHPCS + run: composer run lint + continue-on-error: true + + # ── 300-line file check ────────────────────────────────────────── + file-size: + name: 300-line file check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check no PHP file exceeds 300 lines + run: | + violations=0 + while IFS= read -r file; do + lines=$(wc -l < "$file") + if [ "$lines" -gt 300 ]; then + echo "VIOLATION: $file ($lines lines)" + violations=$((violations + 1)) + fi + done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./tests/*") + if [ "$violations" -gt 0 ]; then + echo "$violations file(s) exceed 300 lines" + exit 1 + fi + echo "All PHP files are 300 lines or fewer." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93d1abd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +vendor/ +node_modules/ +.env +.env.* +*.log +.DS_Store +Thumbs.db diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..afabb7b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,128 @@ +# Peptide Reconstitution Calculator — Architecture + +Peptide Reconstitution Calculator is a WordPress plugin that provides an interactive tool for calculating reconstitution parameters — concentration, injection volume, syringe units, and doses per vial. It integrates with Peptide Repo Core (optional) to provide peptide-specific presets sourced from real dosing data, and falls back to built-in defaults when PR Core is not installed. + +--- + +## Data Flow + +``` + ┌───────────────────────┐ + │ Peptide Repo Core │ (optional dependency) + │ PR_Core_Peptide_Repo │ + │ PR_Core_Dosing_Repo │ + └──────────┬────────────┘ + │ read peptides + dosing rows + ▼ +┌──────────────────┐ ┌───────────────────────┐ ┌──────────────────┐ +│ Default Presets │─▶│ PRC_Preset_Provider │─▶│ Transient Cache │ +│ (hardcoded) │ │ (merge + cache) │ │ prc_presets_* │ +└──────────────────┘ └──────────┬────────────┘ └──────────────────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ REST API │ │ Shortcode │ + │ prc/v1 │ │ [prc_calc] │ + └──────────────┘ │ wp_localize │ + └──────┬───────┘ + │ inline presets + ▼ + ┌──────────────┐ + │ calculator.js│ + │ (client math)│ + └──────────────┘ +``` + +--- + +## File Tree + +``` +peptide-reconstitution-calculator/ +├── peptide-reconstitution-calculator.php # Plugin bootstrap — constants, autoloader, hooks +├── uninstall.php # Teardown: delete prc_ options and transients +├── composer.json # Dev dependencies (PHPCS, WPCS) +├── phpcs.xml.dist # PHPCS ruleset configuration +├── ARCHITECTURE.md # This file +├── CONVENTIONS.md # Naming patterns, extension guides +├── CHANGELOG.md # Semantic versioning changelog +│ +├── .github/ +│ └── workflows/ +│ └── ci.yml # PHP lint (8.1-8.3), PHPCS, 300-line check +│ +├── assets/ +│ ├── css/ +│ │ └── calculator.css # Calculator widget styles (dark mode, responsive) +│ └── js/ +│ └── calculator.js # Client-side calculator logic + preset handling +│ +└── includes/ + ├── class-prc-autoloader.php # SPL autoloader for PRC_ classes + ├── class-prc-calculator.php # Main orchestrator — boots subsystems + ├── class-prc-activator.php # Activation: rewrite flush + ├── class-prc-deactivator.php # Deactivation: rewrite flush + ├── class-prc-preset-provider.php # Bridges PR Core dosing data to presets + ├── class-prc-default-presets.php # Hardcoded fallback presets (8 peptides) + ├── class-prc-math.php # Pure calculation functions (mirrored in JS) + ├── class-prc-cache-listener.php # Invalidates presets on PR Core data changes + │ + ├── api/ + │ └── class-prc-rest-controller.php # REST endpoints: presets list/detail, calculate + │ + └── frontend/ + └── class-prc-shortcode.php # [prc_calculator] shortcode + asset enqueue +``` + +--- + +## External Dependencies + +### Peptide Repo Core (optional) + +- **What for:** Peptide list and subcutaneous dosing rows for live presets. +- **Integration code:** `PRC_Preset_Provider::build_presets_from_pr_core()`. +- **Detection:** `defined( 'PR_CORE_VERSION' )` — graceful fallback if absent. +- **Cache invalidation:** `PRC_Cache_Listener` hooks `pr_core_after_dosing_row_publish` and `pr_core_after_peptide_publish`. + +--- + +## Public API + +### Shortcode + +- `[prc_calculator]` — Renders the full calculator widget. +- `[prc_calculator peptide="bpc-157"]` — Pre-selects a specific peptide. + +### REST API (namespace: `prc/v1`) + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | /presets | Public | List all available presets | +| GET | /presets/{slug} | Public | Get a single preset by slug | +| POST | /calculate | Public | Server-side reconstitution math | + +### Filters + +- `prc_preset_provider_presets` — (planned) Filter the final presets array before caching. + +--- + +## Key Decisions + +### #1: Client-side calculation with server-side mirror + +The JS calculator computes results instantly on every input change (no round-trips). The REST /calculate endpoint provides the same math server-side for validation, API consumers, and future integrations. + +### #2: Graceful degradation without PR Core + +The calculator works standalone with 8 hardcoded popular peptide presets. When PR Core is active, live data is merged in and takes priority. This means the tool is useful from day one even before PR Core is deployed. + +### #3: Inline presets via wp_localize_script + +Presets are injected into the page as a JS variable rather than fetched via REST on page load. This eliminates a round-trip and means the calculator is interactive immediately. The preset payload is small (< 5KB). + +### #4: Transient-cached presets with event-driven invalidation + +PR Core data is cached for 1 hour via WordPress transients. Cache is busted immediately when dosing rows or peptides are published, so the calculator stays in sync without polling. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd17985 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to Peptide Reconstitution Calculator are documented here. +Format: [Semantic Versioning](https://semver.org/). + +## [1.0.0] — 2026-04-17 + +### Added +- Interactive reconstitution calculator via `[prc_calculator]` shortcode. +- Client-side instant calculation: concentration (mg/mL), mcg per syringe unit, injection volume (mL), syringe units (IU), doses per vial. +- Peptide-specific presets from Peptide Repo Core (when active) with live dosing data. +- 8 built-in default presets: BPC-157, TB-500, Semaglutide, CJC-1295 (DAC), Ipamorelin, PT-141, MK-677, GHRP-6. +- Preset pre-selection via shortcode attribute: `[prc_calculator peptide="bpc-157"]`. +- REST API (`prc/v1`): GET /presets, GET /presets/{slug}, POST /calculate. +- Server-side math engine (`PRC_Math`) mirroring client-side calculations. +- Transient-cached presets with event-driven invalidation on PR Core data changes. +- Admin notice when PR Core is not installed (informational, not blocking). +- Dark mode support via `prefers-color-scheme`. +- Responsive design down to 320px viewport. +- Full teardown in `uninstall.php` (options + transients). +- CI workflow: PHP lint (8.1/8.2/8.3), PHPCS WordPress, 300-line file check. diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..81eb275 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,48 @@ +# Peptide Reconstitution Calculator — Conventions + +## Naming + +- **Class prefix:** `PRC_` — all classes use this prefix. +- **Hook prefix:** `prc_` — all actions and filters start with this. +- **Option prefix:** `prc_` — all wp_options keys start with this. +- **Transient prefix:** `prc_` — all transients start with this. +- **CSS prefix:** `.prc-` — all CSS classes. +- **JS global:** `prcConfig` — inline configuration object. +- **Text domain:** `peptide-reconstitution-calculator`. +- **REST namespace:** `prc/v1`. + +## File Naming + +Autoloader convention: `PRC_Foo_Bar` maps to `class-prc-foo-bar.php`. +Files live in `includes/` or one level of subdirectory under `includes/`. + +## How To: Add a New Preset + +1. Add an entry to `PRC_Default_Presets::get_all()` in `includes/class-prc-default-presets.php`. +2. Each preset must include: slug, name, vial_sizes_mg, default_vial_mg, recommended_water_ml, dose_range_min, dose_range_max, dose_unit, typical_frequency, evidence_strength, dosing_row_count, source. + +## How To: Modify Calculation Logic + +1. Update the calculation in `PRC_Math` (`includes/class-prc-math.php`). +2. Mirror the same change in `assets/js/calculator.js` — the `calculate()` function. +3. Both must produce identical results for the same inputs. + +## How To: Add a New Result Field + +1. Add the calculation to `PRC_Math::calculate()`. +2. Add the JS mirror in `calculator.js` `calculate()` function. +3. Add a result card in `PRC_Shortcode::render_calculator_html()`. +4. Add CSS for the new card if needed. + +## Error Handling + +- Math functions return 0 for invalid inputs (division by zero, negative values). +- REST endpoints use WordPress validation callbacks on args. +- The JS calculator hides the results panel when inputs are incomplete. +- The preset provider returns built-in defaults on any PR Core failure. + +## Cache Strategy + +- Presets are cached in a WordPress transient (`prc_presets_all`) for 1 hour. +- Cache is invalidated by `PRC_Cache_Listener` on PR Core dosing/peptide publish events. +- Cache is also invalidated on plugin activation (flush on fresh start). diff --git a/assets/css/calculator.css b/assets/css/calculator.css new file mode 100644 index 0000000..d8d98fa --- /dev/null +++ b/assets/css/calculator.css @@ -0,0 +1,303 @@ +/** + * Peptide Reconstitution Calculator — Frontend Styles + * + * Namespace: .prc- prefix on all classes. + * Design: Clean, card-based layout. Responsive down to 320px. + * Dark mode: respects prefers-color-scheme. + * + * @see includes/frontend/class-prc-shortcode.php — HTML structure. + */ + +/* ── Container ─────────────────────────────────────────────────────── */ + +.prc-calculator { + max-width: 640px; + margin: 2rem auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +/* ── Header ────────────────────────────────────────────────────────── */ + +.prc-calculator__header { + padding: 1.25rem 1.5rem; + background: linear-gradient(135deg, #1e40af, #3b82f6); + color: #ffffff; +} + +.prc-calculator__title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +/* ── Body (form fields) ───────────────────────────────────────────── */ + +.prc-calculator__body { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* ── Field ─────────────────────────────────────────────────────────── */ + +.prc-field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.prc-field--hidden { + display: none; +} + +.prc-field__label { + font-size: 0.875rem; + font-weight: 600; + color: #374151; +} + +.prc-field__row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.prc-field__input { + flex: 1; + padding: 0.625rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 1rem; + color: #111827; + background: #f9fafb; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.prc-field__input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + background: #ffffff; +} + +.prc-field__input--half { + flex: 0 0 48%; +} + +.prc-field__input--unit { + flex: 0 0 80px; +} + +.prc-field__unit { + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; + min-width: 28px; +} + +.prc-field__hint { + font-size: 0.8rem; + color: #6b7280; + margin-top: 0.125rem; +} + +.prc-field__static { + font-size: 0.95rem; + color: #374151; + font-weight: 500; +} + +.prc-field__custom-wrap { + display: flex; + align-items: center; + gap: 0.375rem; + flex: 1; +} + +/* ── Results Panel ─────────────────────────────────────────────────── */ + +.prc-results { + padding: 1.5rem; + border-top: 1px solid #e5e7eb; + background: #f0f9ff; +} + +.prc-results--hidden { + display: none; +} + +.prc-results__title { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: #1e40af; +} + +.prc-results__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; +} + +/* ── Result Card ───────────────────────────────────────────────────── */ + +.prc-result-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + text-align: center; + transition: transform 0.15s ease; +} + +.prc-result-card--primary { + border-color: #3b82f6; + background: #eff6ff; +} + +.prc-result-card__value { + font-size: 1.5rem; + font-weight: 700; + color: #111827; + line-height: 1.2; +} + +.prc-result-card--primary .prc-result-card__value { + color: #1e40af; + font-size: 1.75rem; +} + +.prc-result-card__label { + font-size: 0.75rem; + color: #6b7280; + margin-top: 0.375rem; + line-height: 1.3; +} + +/* ── Disclaimer ────────────────────────────────────────────────────── */ + +.prc-disclaimer { + padding: 0.75rem 1.5rem 1rem; + font-size: 0.75rem; + color: #9ca3af; + line-height: 1.5; + border-top: 1px solid #f3f4f6; +} + +/* ── Noscript ──────────────────────────────────────────────────────── */ + +.prc-noscript { + padding: 1.5rem; + text-align: center; + color: #6b7280; +} + +/* ── Dark Mode ─────────────────────────────────────────────────────── */ + +@media (prefers-color-scheme: dark) { + .prc-calculator { + background: #1f2937; + border-color: #374151; + } + + .prc-calculator__header { + background: linear-gradient(135deg, #1e3a8a, #2563eb); + } + + .prc-field__label { + color: #d1d5db; + } + + .prc-field__input { + background: #111827; + border-color: #4b5563; + color: #f3f4f6; + } + + .prc-field__input:focus { + background: #1f2937; + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); + } + + .prc-field__unit, + .prc-field__hint { + color: #9ca3af; + } + + .prc-field__static { + color: #d1d5db; + } + + .prc-results { + background: #111827; + border-color: #374151; + } + + .prc-results__title { + color: #60a5fa; + } + + .prc-result-card { + background: #1f2937; + border-color: #374151; + } + + .prc-result-card--primary { + background: #172554; + border-color: #2563eb; + } + + .prc-result-card__value { + color: #f3f4f6; + } + + .prc-result-card--primary .prc-result-card__value { + color: #93c5fd; + } + + .prc-result-card__label { + color: #9ca3af; + } + + .prc-disclaimer { + color: #6b7280; + border-color: #374151; + } +} + +/* ── Responsive ────────────────────────────────────────────────────── */ + +@media (max-width: 480px) { + .prc-calculator { + margin: 1rem; + border-radius: 8px; + } + + .prc-calculator__body { + padding: 1rem; + } + + .prc-results { + padding: 1rem; + } + + .prc-results__grid { + grid-template-columns: repeat(2, 1fr); + } + + .prc-field__row { + flex-wrap: wrap; + } + + .prc-field__input--half { + flex: 1 1 100%; + } +} diff --git a/assets/js/calculator.js b/assets/js/calculator.js new file mode 100644 index 0000000..7bc1147 --- /dev/null +++ b/assets/js/calculator.js @@ -0,0 +1,309 @@ +/** + * Peptide Reconstitution Calculator — Client-side logic. + * + * What: Drives the interactive calculator widget. Populates the peptide + * dropdown from inline presets, auto-fills fields on selection, + * runs reconstitution math on every input change, and renders results. + * Dependencies: prcConfig global (set by wp_localize_script in PRC_Shortcode). + * + * @see includes/frontend/class-prc-shortcode.php — HTML structure + config injection. + * @see includes/class-prc-math.php — Server-side mirror of the math. + */ + +(function () { + 'use strict'; + + /* ── Constants ────────────────────────────────────────────────────── */ + + var SYRINGE_UNITS_PER_ML = 100; + + /* ── State ────────────────────────────────────────────────────────── */ + + var presets = []; + var currentPreset = null; + + /* ── DOM References ──────────────────────────────────────────────── */ + + var els = {}; + + /* ── Init ─────────────────────────────────────────────────────────── */ + + function init() { + var container = document.querySelector('.prc-calculator'); + if (!container) { + return; + } + + cacheDomRefs(); + loadPresets(); + populatePeptideDropdown(); + bindEvents(); + renderDisclaimer(); + + // Auto-select peptide if data attribute is set. + var preselected = container.getAttribute('data-selected-slug'); + if (preselected) { + els.peptideSelect.value = preselected; + onPeptideChange(); + } + } + + function cacheDomRefs() { + els.peptideSelect = document.getElementById('prc-peptide-select'); + els.vialSelect = document.getElementById('prc-vial-select'); + els.vialMg = document.getElementById('prc-vial-mg'); + els.waterMl = document.getElementById('prc-water-ml'); + els.desiredDose = document.getElementById('prc-desired-dose'); + els.doseUnit = document.getElementById('prc-dose-unit'); + els.doseRange = document.getElementById('prc-dose-range'); + els.frequencyRow = document.getElementById('prc-frequency-row'); + els.frequencyValue = document.getElementById('prc-frequency-value'); + els.results = document.getElementById('prc-results'); + els.concentration = document.getElementById('prc-result-concentration'); + els.mcgPerUnit = document.getElementById('prc-result-mcg-per-unit'); + els.syringeUnits = document.getElementById('prc-result-syringe-units'); + els.injectionMl = document.getElementById('prc-result-injection-ml'); + els.doses = document.getElementById('prc-result-doses'); + els.disclaimer = document.getElementById('prc-disclaimer'); + } + + function loadPresets() { + if (typeof prcConfig !== 'undefined' && prcConfig.presets) { + presets = prcConfig.presets; + } + } + + /* ── Dropdown Population ─────────────────────────────────────────── */ + + function populatePeptideDropdown() { + var select = els.peptideSelect; + if (!select) { + return; + } + + // Clear existing options except the first placeholder. + while (select.options.length > 1) { + select.remove(1); + } + + // Add preset options. + presets.forEach(function (preset) { + var opt = document.createElement('option'); + opt.value = preset.slug; + opt.textContent = preset.name; + if (preset.source === 'pr_core') { + opt.textContent += ' ★'; + } + select.appendChild(opt); + }); + + // Add custom entry option. + var customOpt = document.createElement('option'); + customOpt.value = '__custom__'; + var i18n = (typeof prcConfig !== 'undefined' && prcConfig.i18n) ? prcConfig.i18n : {}; + customOpt.textContent = i18n.customEntry || 'Custom / Manual Entry'; + select.appendChild(customOpt); + } + + /* ── Event Binding ───────────────────────────────────────────────── */ + + function bindEvents() { + if (els.peptideSelect) { + els.peptideSelect.addEventListener('change', onPeptideChange); + } + if (els.vialSelect) { + els.vialSelect.addEventListener('change', onVialSelectChange); + } + if (els.vialMg) { + els.vialMg.addEventListener('input', calculate); + } + if (els.waterMl) { + els.waterMl.addEventListener('input', calculate); + } + if (els.desiredDose) { + els.desiredDose.addEventListener('input', calculate); + } + if (els.doseUnit) { + els.doseUnit.addEventListener('change', calculate); + } + } + + /* ── Event Handlers ──────────────────────────────────────────────── */ + + function onPeptideChange() { + var slug = els.peptideSelect.value; + + if (slug === '__custom__' || slug === '') { + currentPreset = null; + clearPresetFields(); + hideResults(); + return; + } + + currentPreset = presets.find(function (p) { return p.slug === slug; }); + if (!currentPreset) { + clearPresetFields(); + return; + } + + applyPreset(currentPreset); + calculate(); + } + + function onVialSelectChange() { + var val = els.vialSelect.value; + if (val === '__custom__') { + els.vialMg.value = ''; + els.vialMg.focus(); + } else { + els.vialMg.value = val; + } + calculate(); + } + + /* ── Preset Application ──────────────────────────────────────────── */ + + function applyPreset(preset) { + // Populate vial size dropdown. + populateVialDropdown(preset.vial_sizes_mg, preset.default_vial_mg); + + // Set vial mg input. + els.vialMg.value = preset.default_vial_mg; + + // Set recommended water volume. + els.waterMl.value = preset.recommended_water_ml; + + // Set dose unit. + els.doseUnit.value = preset.dose_unit; + + // Set a sensible default dose (midpoint of range). + if (preset.dose_range_min !== null && preset.dose_range_max !== null) { + var mid = (preset.dose_range_min + preset.dose_range_max) / 2; + // Round to nearest sensible number. + if (preset.dose_unit === 'mcg') { + els.desiredDose.value = Math.round(mid / 25) * 25; + } else { + els.desiredDose.value = Math.round(mid * 100) / 100; + } + } + + // Show dose range hint. + if (preset.dose_range_min !== null && preset.dose_range_max !== null) { + els.doseRange.textContent = + 'Typical range: ' + preset.dose_range_min + '–' + preset.dose_range_max + ' ' + preset.dose_unit; + } else { + els.doseRange.textContent = ''; + } + + // Show frequency if available. + if (preset.typical_frequency) { + els.frequencyRow.classList.remove('prc-field--hidden'); + els.frequencyValue.textContent = preset.typical_frequency; + } else { + els.frequencyRow.classList.add('prc-field--hidden'); + } + } + + function populateVialDropdown(sizes, defaultSize) { + var select = els.vialSelect; + select.innerHTML = ''; + + sizes.forEach(function (size) { + var opt = document.createElement('option'); + opt.value = size; + opt.textContent = size + ' mg'; + if (size === defaultSize) { + opt.selected = true; + } + select.appendChild(opt); + }); + + // Add custom option. + var customOpt = document.createElement('option'); + customOpt.value = '__custom__'; + customOpt.textContent = 'Custom…'; + select.appendChild(customOpt); + } + + function clearPresetFields() { + els.vialSelect.innerHTML = ''; + els.vialMg.value = ''; + els.waterMl.value = ''; + els.desiredDose.value = ''; + els.doseRange.textContent = ''; + els.frequencyRow.classList.add('prc-field--hidden'); + } + + /* ── Calculation ─────────────────────────────────────────────────── */ + + function calculate() { + var vialMg = parseFloat(els.vialMg.value); + var waterMl = parseFloat(els.waterMl.value); + var desiredDose = parseFloat(els.desiredDose.value); + var doseUnit = els.doseUnit.value; + + // Need all three inputs to calculate. + if (isNaN(vialMg) || isNaN(waterMl) || isNaN(desiredDose)) { + hideResults(); + return; + } + + if (vialMg <= 0 || waterMl <= 0 || desiredDose <= 0) { + hideResults(); + return; + } + + // Normalize dose to mg. + var doseMg = (doseUnit === 'mcg') ? desiredDose / 1000 : desiredDose; + + // Core math — mirrors PRC_Math::calculate(). + var concentrationMgMl = vialMg / waterMl; + var totalMcg = vialMg * 1000; + var totalUnits = waterMl * SYRINGE_UNITS_PER_ML; + var mcgPerUnit = totalMcg / totalUnits; + var injectionMl = doseMg / concentrationMgMl; + var syringeUnits = injectionMl * SYRINGE_UNITS_PER_ML; + var dosesPerVial = Math.floor(vialMg / doseMg); + + // Render results. + els.concentration.textContent = round(concentrationMgMl, 2) + ' mg/mL'; + els.mcgPerUnit.textContent = round(mcgPerUnit, 2) + ' mcg'; + els.syringeUnits.textContent = round(syringeUnits, 1) + ' IU'; + els.injectionMl.textContent = round(injectionMl, 3) + ' mL'; + els.doses.textContent = dosesPerVial; + + showResults(); + } + + /* ── UI Helpers ───────────────────────────────────────────────────── */ + + function showResults() { + els.results.classList.remove('prc-results--hidden'); + } + + function hideResults() { + els.results.classList.add('prc-results--hidden'); + } + + function renderDisclaimer() { + var i18n = (typeof prcConfig !== 'undefined' && prcConfig.i18n) ? prcConfig.i18n : {}; + var text = i18n.disclaimer || + 'For informational purposes only. Not medical advice. Always consult a qualified healthcare professional.'; + if (els.disclaimer) { + els.disclaimer.textContent = text; + } + } + + function round(value, decimals) { + var factor = Math.pow(10, decimals); + return Math.round(value * factor) / factor; + } + + /* ── Boot ─────────────────────────────────────────────────────────── */ + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..392da5e --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "peptiderepo/peptide-reconstitution-calculator", + "description": "Reconstitution calculator with peptide-specific presets from PR Core.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.1" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.13", + "wp-coding-standards/wpcs": "^3.0", + "phpcsstandards/phpcsutils": "^1.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "lint": "phpcs", + "lint:fix": "phpcbf" + } +} diff --git a/includes/api/class-prc-rest-controller.php b/includes/api/class-prc-rest-controller.php new file mode 100644 index 0000000..ae19ba2 --- /dev/null +++ b/includes/api/class-prc-rest-controller.php @@ -0,0 +1,167 @@ + 'GET', + 'callback' => [ $this, 'get_presets' ], + 'permission_callback' => '__return_true', + ] + ); + + register_rest_route( + self::NAMESPACE, + '/presets/(?P[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> + */ + 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 ), + ], + ]; + } +} diff --git a/includes/class-prc-activator.php b/includes/class-prc-activator.php new file mode 100644 index 0000000..79c67de --- /dev/null +++ b/includes/class-prc-activator.php @@ -0,0 +1,28 @@ +invalidate_cache(); + } +} diff --git a/includes/class-prc-calculator.php b/includes/class-prc-calculator.php new file mode 100644 index 0000000..359612b --- /dev/null +++ b/includes/class-prc-calculator.php @@ -0,0 +1,86 @@ +id, $show_on, true ) ) { + return; + } + + printf( + '

%s

', + 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' + ) + ); + } +} diff --git a/includes/class-prc-deactivator.php b/includes/class-prc-deactivator.php new file mode 100644 index 0000000..3c406c7 --- /dev/null +++ b/includes/class-prc-deactivator.php @@ -0,0 +1,27 @@ +> + */ + 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', + ], + ]; + } +} diff --git a/includes/class-prc-math.php b/includes/class-prc-math.php new file mode 100644 index 0000000..25fb02e --- /dev/null +++ b/includes/class-prc-math.php @@ -0,0 +1,146 @@ + 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; + } +} diff --git a/includes/class-prc-preset-provider.php b/includes/class-prc-preset-provider.php new file mode 100644 index 0000000..9d477d7 --- /dev/null +++ b/includes/class-prc-preset-provider.php @@ -0,0 +1,241 @@ +> 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> + */ + 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|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 ); + } +} diff --git a/includes/frontend/class-prc-shortcode.php b/includes/frontend/class-prc-shortcode.php new file mode 100644 index 0000000..41e13e8 --- /dev/null +++ b/includes/frontend/class-prc-shortcode.php @@ -0,0 +1,241 @@ +|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 { + ?> +
+
+

+ +

+
+ +
+ +
+ + +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+
+ + +
+

+ +

+
+
+
—
+
+
+
+
—
+
+
+
+
—
+
+
+
+
—
+
+
+
+
—
+
+
+
+
+ + +
+ + +
+ + + WordPress coding standards for Peptide Reconstitution Calculator. + + . + + /vendor/* + /node_modules/* + /tests/* + /assets/* + + + + + + + + diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..8b9b39a --- /dev/null +++ b/uninstall.php @@ -0,0 +1,35 @@ +query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'prc_' ) . '%' + ) +); + +// Delete all prc_ prefixed transients. +$wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( '_transient_prc_' ) . '%', + $wpdb->esc_like( '_transient_timeout_prc_' ) . '%' + ) +);