From c17553eac1b82847eabcb8311b726e597862a67a Mon Sep 17 00:00:00 2001 From: stenehrlich-tuleva Date: Tue, 4 Aug 2026 14:27:49 +0300 Subject: [PATCH 1/2] Wire homepage investor count to the live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "N Eesti inimest kogub Tuleva indeksfondides" figure on the front page was typed in by hand in wp-admin, and had not been touched since early May 2026 — 85 224 displayed against an actual 86 233. onboarding-service PR #1643 shipped GET /v1/statistics/investor-count in June specifically to automate this, but nothing ever consumed it. This is the missing half: credentials.php and team-hero.php now read the live count, falling back to the members_count ACF field if the call fails, so the block can never render blank or zero. Replaces the dead get_member_count() helper, which was referenced nowhere and pointed at /v1/members (ühistu members, 9 729 — the wrong metric). It also called stream_context_set_default(), which mutated the default stream context for the whole request. The API response is cached for a day. mv_kpi_new only moves once a month, so the displayed number changes about monthly while staying at most a day behind. A failed call is cached for five minutes and the 1s timeout is bounded (verified: an unresponsive host returns after 1010ms), so an outage costs one slow render per five minutes rather than one per visitor. Co-Authored-By: Claude Opus 5 (1M context) --- .../themes/tuleva/helpers/extras.php | 50 +++++++++++++++---- .../templates/components/credentials.php | 2 +- .../tuleva/templates/components/team-hero.php | 2 +- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/wp-content/themes/tuleva/helpers/extras.php b/src/wp-content/themes/tuleva/helpers/extras.php index d4738a7d..a6831bf1 100644 --- a/src/wp-content/themes/tuleva/helpers/extras.php +++ b/src/wp-content/themes/tuleva/helpers/extras.php @@ -314,21 +314,49 @@ function get_app_url($path) } /* - * Get Tuleva member count + * Get the number of people saving in Tuleva index funds. + * + * Source: onboarding-service, backed by the same analytics.mv_kpi_new figure as the + * Metabase "kogujate arv". That view only moves once a month, so the value is cached + * for a day: the count changes about monthly, and we are never more than a day behind + * when it does. + * + * Returns 0 on any failure, so callers can fall back to the members_count ACF field. */ -function get_member_count() +function get_investor_count() { - stream_context_set_default( - array( - 'http' => array( - 'method' => 'HEAD' - ) - ) + $cached = get_transient('tuleva_investor_count'); + if ($cached !== false) { + return (int) $cached; + } + + $context = stream_context_create( + [ + 'http' => [ + 'method' => 'GET', + 'timeout' => 1, + ] + ] + ); + $json = @file_get_contents( + 'https://onboarding-service.tuleva.ee/v1/statistics/investor-count', + false, + $context ); - $headers = get_headers('https://onboarding-service.tuleva.ee/v1/members', 1); - $memberCount = empty($headers['X-Total-Count']) ? 0 : $headers['X-Total-Count']; + $data = json_decode($json, true); + $count = isset($data['count']) ? (int) $data['count'] : 0; + + if ($count < 50000) { + // Unreachable, malformed or implausible. Cache the failure briefly so an + // outage costs one slow request per five minutes, not one per visitor. + set_transient('tuleva_investor_count', 0, 5 * MINUTE_IN_SECONDS); + + return 0; + } + + set_transient('tuleva_investor_count', $count, DAY_IN_SECONDS); - return $memberCount; + return $count; } function print_funds_js() diff --git a/src/wp-content/themes/tuleva/templates/components/credentials.php b/src/wp-content/themes/tuleva/templates/components/credentials.php index cf41874a..22630c66 100644 --- a/src/wp-content/themes/tuleva/templates/components/credentials.php +++ b/src/wp-content/themes/tuleva/templates/components/credentials.php @@ -1,5 +1,5 @@ Date: Fri, 7 Aug 2026 11:39:23 +0300 Subject: [PATCH 2/2] Address review findings: keep last good count, guard localhost, validate value Follow-up to the review pass on this branch. Four fixes: 1. team-hero.php no longer calls the API. It assigns $members_count but never renders it (the assignment was already dead code on master), so wiring it up turned a free get_field() into a blocking HTTP call for a number that is never displayed. Reverted to match master exactly. 2. A failed refresh no longer falls back to the hand-entered ACF field. The last count the API returned successfully is kept in an option, so an outage keeps showing yesterday's real number instead of dropping to members_count, which can be months stale. Previously, one failed refresh after a day of correct values would have put 85 224 back on the front page. 3. Added the localhost guard the four fund templates already use, so local dev stops calling the production API and polluting its observability. 4. Value validation: require is_numeric, so a malformed {"count":"70000abc"} is rejected rather than cast to 70000, and mirror the endpoint's own SQL sanity bounds with an upper limit of 500000 alongside the existing floor. Verified with a harness that runs the real function source against stubbed WordPress functions, with get_transient modelling the options-table backend returning scalars as strings: 21/21 checks pass, covering the live endpoint, cache hit, failure-keeps-last-good, cold-start-falls-through-to-ACF, the localhost guard, and eight value-validation cases. Cache stampede on transient expiry is a known remaining gap, reduced but not closed by the last-good fallback. Left for a follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- .../themes/tuleva/helpers/extras.php | 34 ++++++++++++++++--- .../tuleva/templates/components/team-hero.php | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/wp-content/themes/tuleva/helpers/extras.php b/src/wp-content/themes/tuleva/helpers/extras.php index a6831bf1..1a940262 100644 --- a/src/wp-content/themes/tuleva/helpers/extras.php +++ b/src/wp-content/themes/tuleva/helpers/extras.php @@ -321,13 +321,26 @@ function get_app_url($path) * for a day: the count changes about monthly, and we are never more than a day behind * when it does. * - * Returns 0 on any failure, so callers can fall back to the members_count ACF field. + * The last count the API returned successfully is also kept in an option. A failed + * refresh then keeps showing yesterday's real number instead of dropping back to the + * hand-entered members_count ACF field, which can be months out of date. + * + * Returns 0 only when there is nothing better to show, so callers can fall back to + * the ACF field. */ function get_investor_count() { + // Same guard as the fund templates: never call the production API from local dev. + if (isset($_SERVER['SERVER_NAME']) && $_SERVER['SERVER_NAME'] === 'localhost') { + return 0; + } + $cached = get_transient('tuleva_investor_count'); if ($cached !== false) { - return (int) $cached; + $cached = (int) $cached; + + // A cached 0 is the failure sentinel, not a real count. + return $cached > 0 ? $cached : get_last_good_investor_count(); } $context = stream_context_create( @@ -344,21 +357,32 @@ function get_investor_count() $context ); $data = json_decode($json, true); - $count = isset($data['count']) ? (int) $data['count'] : 0; + // is_numeric, so that a malformed "70000abc" is rejected rather than cast to 70000. + $count = isset($data['count']) && is_numeric($data['count']) ? (int) $data['count'] : 0; - if ($count < 50000) { + // Mirrors the sanity bounds the endpoint already applies in SQL. + if ($count < 50000 || $count > 500000) { // Unreachable, malformed or implausible. Cache the failure briefly so an // outage costs one slow request per five minutes, not one per visitor. set_transient('tuleva_investor_count', 0, 5 * MINUTE_IN_SECONDS); - return 0; + return get_last_good_investor_count(); } set_transient('tuleva_investor_count', $count, DAY_IN_SECONDS); + update_option('tuleva_investor_count_last_good', $count, false); return $count; } +/* + * The last count the API returned successfully, or 0 if it has never answered. + */ +function get_last_good_investor_count() +{ + return (int) get_option('tuleva_investor_count_last_good', 0); +} + function print_funds_js() { $context = stream_context_create( diff --git a/src/wp-content/themes/tuleva/templates/components/team-hero.php b/src/wp-content/themes/tuleva/templates/components/team-hero.php index f5c2e498..8adf48c9 100644 --- a/src/wp-content/themes/tuleva/templates/components/team-hero.php +++ b/src/wp-content/themes/tuleva/templates/components/team-hero.php @@ -4,7 +4,7 @@ $image_url = wp_get_attachment_image_url($image['ID'], 'large'); $image_srcset = wp_get_attachment_image_srcset($image['ID'],'large'); $button_color_class = get_component_button_color_class(); -$members_count = get_investor_count() ?: get_field('members_count', 'option'); +$members_count = get_field('members_count', 'option'); $members_count_description = get_sub_field('members_count_description'); $security_text = get_sub_field('security_text'); $security_link_text = get_sub_field('security_link_text');