PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-optimize-runner.php +459 -22 1.2.41.3.2 View file →
@@ -30,16 +30,57 @@
30 30 *
31 31 * So the report names what it could not fix and why. A run that changes nothing
32 32 * is a legitimate outcome, reported as such.
33 33 *
34 + * ## Why the score is measured rather than remembered
35 + *
36 + * The same honesty problem applies to the number itself. This read the last
37 + * stored audit, which on a site measured a fortnight ago meant applying six
38 + * changes and then reporting a two-week-old 77 as the outcome — a stale figure
39 + * presented in the position a reader takes for a result.
40 + *
41 + * `measure_score` decides what to spend on avoiding that:
42 + *
43 + * - `auto` (default) — measure when the stored score is stale, and always
44 + * after changes land, subject to the cooldown in Optimize_Diagnosis.
45 + * - `never` — the pre-1.2.0 behaviour, for callers that must not spend a
46 + * measurement.
47 + * - `always` — measure even for a dry run.
48 + *
49 + * Every score now carries `age_seconds`, and the summary sentence names it, so
50 + * a number that could not be refreshed is still readable as old rather than
51 + * passing for fresh.
52 + *
53 + * ## One pass, and the seam for more
54 + *
55 + * This runs ONE pass: every step the tier allows, then stop. That is the whole
56 + * of Free's behaviour and it is deliberate — a pass has a bounded cost, and
57 + * chasing a target score does not.
58 + *
59 + * Repeating the cycle until a site reaches, say, 90 means measuring after each
60 + * round and planning the next against whatever metric is actually weak, which
61 + * is several measurements per site and only pays off across a fleet. That loop
62 + * is Pro's; `xspeed_optimize_report` is where it attaches, so it never needs to
63 + * fork this file. `rounds` in the report says how many passes produced it, and
64 + * is 1 for everything Free does on its own.
65 + *
34 66 * @since 1.2.0
35 67 */
36 68 final class Optimize_Runner {
37 69
38 70 /**
71 + * How many pages OUR OWN suggestions offer for post-run checking.
72 + *
73 + * Applied before `xspeed_optimize_verify_urls` runs, so a site can add
74 + * its own risky template without ours crowding it out. Three is the point
75 + * where a person still opens all of them.
76 + */
77 + private const VERIFY_URL_LIMIT = 3;
78 +
79 + /**
39 80 * Run the whole thing.
40 81 *
41 - * @param array{aggressiveness?:string,dry_run?:bool,budget_seconds?:int,url?:string} $args Options.
82 + * @param array{aggressiveness?:string,dry_run?:bool,budget_seconds?:int,url?:string,measure_score?:string,round?:int} $args Options.
42 83 * @return array<string,mixed>|\WP_Error
43 84 */
44 85 public static function run( array $args = array() ) {
45 86 $aggressiveness = (string) ( $args['aggressiveness'] ?? Optimize_Plan::TIER_STANDARD );
@@ -45,9 +86,58 @@
45 86 $aggressiveness = (string) ( $args['aggressiveness'] ?? Optimize_Plan::TIER_STANDARD );
46 87 $dry_run = (bool) ( $args['dry_run'] ?? false );
47 88 $budget = (int) ( $args['budget_seconds'] ?? 120 );
48 89 $url = (string) ( $args['url'] ?? home_url( '/' ) );
90 + $measure = (string) ( $args['measure_score'] ?? 'auto' );
49 91
92 + // Which round this is. A listener on xspeed_optimize_report re-enters
93 + // run() for round 2 onward and passes this, so the filter context
94 + // reports the true round rather than always claiming to be the first.
95 + $round = max( 1, (int) ( $args['round'] ?? 1 ) );
96 +
97 + // Arguments Free does not act on, forwarded to the seam so a listener
98 + // can. `target_score` / `max_rounds` are the multi-round tuner's, and
99 + // they arrive here from the MCP tool and the CLI like any other
100 + // argument — Free's job is to carry them, not to understand them.
101 + //
102 + // Clamped on the way through, not passed on raw. Free does not act on
103 + // these, but it is the only place that sees them before a listener
104 + // does, and a tuner handed `target_score = 9000` would chase a score
105 + // that cannot exist — burning rounds and measurements on an
106 + // unreachable goal. A Lighthouse score is 0-100 and a round count
107 + // below 1 is not a request. (#306 QA, minor 2)
108 + $bounds = array(
109 + 'target_score' => array( 0, 100 ),
110 + 'max_rounds' => array( 1, 10 ),
111 + );
112 + $passthrough = array();
113 + foreach ( $bounds as $key => list( $min, $max ) ) {
114 + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
115 + $passthrough[ $key ] = max( $min, min( $max, (int) $args[ $key ] ) );
116 + }
117 + }
118 +
119 + // Built once, passed to every seam call, so the two report paths cannot
120 + // hand a listener different context for the same run.
121 + $context = array_merge(
122 + array(
123 + 'aggressiveness' => $aggressiveness,
124 + 'measure_score' => $measure,
125 + 'budget_seconds' => $budget,
126 + 'url' => $url,
127 + 'round' => $round,
128 + ),
129 + $passthrough
130 + );
131 +
132 + if ( ! in_array( $measure, array( 'auto', 'never', 'always' ), true ) ) {
133 + return new \WP_Error(
134 + 'xspeed_optimize_measure_score',
135 + __( 'measure_score must be auto, never or always.', 'xspeed' ),
136 + array( 'status' => 400 )
137 + );
138 + }
139 +
50 140 if ( ! in_array( $aggressiveness, array( Optimize_Plan::TIER_SAFE, Optimize_Plan::TIER_STANDARD, Optimize_Plan::TIER_AGGRESSIVE ), true ) ) {
51 141 return new \WP_Error(
52 142 'xspeed_optimize_aggressiveness',
53 143 __( 'Aggressiveness must be safe, standard or aggressive.', 'xspeed' ),
@@ -59,12 +149,17 @@
59 149 $current = self::current_settings();
60 150 $plan = Optimize_Plan::build( $current, $aggressiveness );
61 151
62 152 if ( $dry_run ) {
63 - $preview = Optimize_Diagnosis::build( $current );
64 - return array(
153 + // A dry run changes nothing, so it has nothing to prove with a
154 + // fresh number — and starting a PSI run for a preview is the
155 + // surprise spend the cooldown exists to prevent. `always` is still
156 + // honoured: someone explicitly asking to measure gets a
157 + // measurement.
158 + $preview = Optimize_Diagnosis::build( $current, self::score_for( 'always' === $measure ? 'always' : 'never' ) );
159 + $out = array(
65 160 'dry_run' => true,
66 - 'message' => self::summary( $preview, 0 ),
161 + 'message' => self::summary( $preview, 0, count( $plan['steps'] ) ),
67 162 'score' => $preview['score'],
68 163 'plan' => array_map(
69 164 static function ( $s ) {
70 165 return array(
@@ -78,8 +173,20 @@
78 173 'skipped' => $plan['skipped'],
79 174 'next_steps' => $preview['agent_fixable'],
80 175 'unfixable' => $preview['human_fixable'],
81 176 );
177 +
178 + // A preview asked to reach a target owes the same explanation a
179 + // real run gives. The tool description tells an assistant to read
180 + // `stopped_because` and relay the reason, so leaving it off a
181 + // preview meant the assistant either said nothing about the target
182 + // or invented a reason. The real-run path sets this below; a dry
183 + // run returns before reaching it. (QA #306, issue 3)
184 + if ( isset( $context['target_score'] ) ) {
185 + $out['stopped_because'] = __( 'A target score was requested, but this was a preview — no changes were applied and the target was not chased.', 'xspeed' );
186 + }
187 +
188 + return $out;
82 189 }
83 190
84 191 // Nothing to do is a real answer, and a common one on a site that has
85 192 // already been tuned. Returning early avoids spending two benchmarks
@@ -87,20 +194,34 @@
87 194 if ( array() === $plan['steps'] ) {
88 195 // The case that most needs a diagnosis attached. "Nothing to do"
89 196 // on a site scoring 50 is not an answer — it is the start of the
90 197 // conversation about what is actually wrong and who can fix it.
91 - $diagnosis = Optimize_Diagnosis::build( $current );
92 - return array(
93 - 'before' => null,
94 - 'applied' => array(),
95 - 'skipped' => $plan['skipped'],
96 - 'reverted' => array(),
97 - 'after' => null,
98 - 'score' => $diagnosis['score'],
99 - 'next_steps' => $diagnosis['agent_fixable'],
100 - 'unfixable' => $diagnosis['human_fixable'],
101 - 'verified' => true,
102 - 'message' => self::summary( $diagnosis, 0 ),
198 + // The score carries this answer entirely. "Nothing to do" is only
199 + // useful next to a number that is true NOW — beside a stale one it
200 + // is indistinguishable from "we looked a fortnight ago".
201 + $diagnosis = Optimize_Diagnosis::build( $current, self::score_for( $measure ) );
202 +
203 + // This path fires the seam too. It is the case a multi-round tuner
204 + // most needs to see: nothing left at THIS tier, with a score that
205 + // says whether the target was reached. Returning early without
206 + // filtering would hide the "already tuned and still short" state —
207 + // the one where a listener has to decide between escalating the
208 + // tier and stopping honestly.
209 + return self::filter_report(
210 + array(
211 + 'before' => null,
212 + 'applied' => array(),
213 + 'skipped' => $plan['skipped'],
214 + 'reverted' => array(),
215 + 'after' => null,
216 + 'score' => $diagnosis['score'],
217 + 'next_steps' => $diagnosis['agent_fixable'],
218 + 'unfixable' => $diagnosis['human_fixable'],
219 + 'verified' => true,
220 + 'message' => self::summary( $diagnosis, 0 ),
221 + 'rounds' => $round,
222 + ),
223 + $context
103 224 );
104 225 }
105 226
106 227 // --- 2. Measure -------------------------------------------------
@@ -160,11 +281,27 @@
160 281
161 282 // Re-read settings: what is still off AFTER this run is what an
162 283 // aggressive run could try next, and offering a step we just applied
163 284 // would be nonsense.
164 - $diagnosis = Optimize_Diagnosis::build( self::current_settings() );
285 + // Changes landed, so the stored score now describes a site that no
286 + // longer exists. This is the one path where staleness is guaranteed
287 + // rather than likely, so the age check is skipped instead of asking
288 + // whether six hours have passed.
289 + //
290 + // The COOLDOWN still applies, though — which is what the previous
291 + // form got wrong. It passed `'always'`, and `'always'` means $force,
292 + // which skips the cooldown as well: so every run that applied
293 + // anything spent a measurement, on the default `auto`, ignoring the
294 + // rate limit the manual promises. The nothing-to-do path was the only
295 + // one actually held back, i.e. the case that matters least.
296 + // A run inside the cooldown now falls back to the stored number with
297 + // its age attached rather than billing the quota. (#306 QA issue 1)
298 + $diagnosis = Optimize_Diagnosis::build(
299 + self::current_settings(),
300 + self::score_for( $measure, true )
301 + );
165 302
166 - return array(
303 + $report = array(
167 304 'before' => $before,
168 305 'applied' => $result['applied'],
169 306 'skipped' => array_merge( $plan['skipped'], $result['skipped'] ),
170 307 'reverted' => $result['reverted'],
@@ -173,12 +310,272 @@
173 310 'next_steps' => $diagnosis['agent_fixable'],
174 311 'unfixable' => $diagnosis['human_fixable'],
175 312 'verified' => array() === $result['reverted'],
176 313 'message' => self::summary( $diagnosis, count( $result['applied'] ) ),
314 + 'rounds' => $round,
177 315 );
316 +
317 + // What `verified` does NOT cover, said out loud, next to the field it
318 + // qualifies. Optimize_Verifier reads HTML in PHP: it catches a fatal, a
319 + // truncated document, a stylesheet that vanished. It cannot execute
320 + // JavaScript, so a page can arrive structurally perfect and still be
321 + // broken in a browser — removing jQuery Migrate did exactly that, with
322 + // intact HTML and `e.indexOf is not a function` at runtime.
323 + //
324 + // The caller usually CAN look: an AI assistant driving this has a
325 + // browser, and a person has one by definition. Nothing was asking them
326 + // to. These two fields are the ask, and they are only attached when a
327 + // run actually changed something — a pass that applied nothing has
328 + // nothing new to check.
329 + if ( array() !== $result['applied'] ) {
330 + $report['verify_urls'] = self::verify_urls( $url );
331 + $report['verify_note'] = __( 'Changes were applied and the HTML checks passed, which does not prove the page works in a browser — those checks cannot run JavaScript. Open these URLs and confirm each renders correctly with no console errors. If you cannot open them, tell the user to check them and what a problem would look like.', 'xspeed' );
332 + }
333 +
334 + return self::filter_report( $report, $context );
178 335 }
179 336
180 337 /**
338 + * Fire the report seam.
339 + *
340 + * Both exit paths that produce a report go through here — the applied-run
341 + * one and the nothing-to-do one — so a listener cannot silently miss half
342 + * the outcomes.
343 + *
344 + * @param array<string,mixed> $report The finished report.
345 + * @param array<string,mixed> $context Seam context — see the filter docblock below.
346 + * @return array<string,mixed>
347 + */
348 + private static function filter_report( array $report, array $context ): array {
349 + /**
350 + * Filter the finished report, allowing a listener to run further rounds.
351 + *
352 + * The seam exists because one pass cannot chase a target score. This
353 + * pass applies every step the tier allows and stops; whether that got
354 + * anywhere near 90 is unknown until it is measured, and acting on the
355 + * answer means planning again against the metric that is actually
356 + * weak. That loop is Pro's (FEATURES.md, Score card #9) — but it must
357 + * not require forking this file, so it hooks here.
358 + *
359 + * A listener is expected to RE-ENTER this method for each additional
360 + * round and merge the results, rather than reimplementing the apply
361 + * loop. Everything that makes a round safe — baseline sampling,
362 + * one-step-at-a-time application, revert-on-breakage, the budget
363 + * check — lives in Optimizer::run() and is not worth a second
364 + * implementation that can drift out of agreement with this one.
365 + *
366 + * Re-entrancy is the listener's problem to handle: unhook before
367 + * recursing, or guard on `$context['round']`, or this filter fires
368 + * again inside its own callback and recurses without end.
369 + *
370 + * Whatever a listener returns is what callers see, so it must keep the
371 + * report's shape and its honesty — `unfixable` in particular is the
372 + * part that stops a tuner claiming a win it did not make.
373 + *
374 + * @since 1.2.0
375 + *
376 + * @param array<string,mixed> $report The finished report.
377 + * @param array<string,mixed> $context {
378 + * What a further round needs to plan and run.
379 + *
380 + * @type string $aggressiveness Tier this run used.
381 + * @type string $measure_score The caller's measure_score.
382 + * @type int $budget_seconds Per-round time budget.
383 + * @type string $url URL sampled for verification.
384 + * @type int $round 1-based index of the round just finished.
385 + * @type int $target_score Optional. Score the caller asked to reach.
386 + * Free does not act on it; it is carried
387 + * here so a listener can. Absent unless the
388 + * caller sent it.
389 + * @type int $max_rounds Optional. Ceiling the caller asked for,
390 + * same arrangement.
391 + * }
392 + */
393 + $filtered = apply_filters(
394 + 'xspeed_optimize_report',
395 + $report,
396 + $context
397 + );
398 +
399 + // A listener that returns a non-array — or nothing, the easy mistake in
400 + // a filter callback written as an action — must not turn a completed
401 + // run into a fatal downstream. Fall back to the unfiltered report.
402 + //
403 + // An EMPTY array is the same mistake wearing a different hat: it is
404 + // technically an array, so it passed this guard and blanked the whole
405 + // report, losing what was applied on a run that genuinely changed the
406 + // site. A listener with nothing to add returns the report it was
407 + // given; one that returns nothing at all has failed, and the honest
408 + // answer is our own report rather than silence. (#306 QA, minor 1)
409 + $out = ( is_array( $filtered ) && array() !== $filtered ) ? $filtered : $report;
410 +
411 + // The caller asked to reach a score and nothing chased it: no listener
412 + // is installed, so this was a single pass. Say so. The tool
413 + // description tells an assistant to read `stopped_because` and relay
414 + // it, and nothing ever set it — leaving the assistant to either stay
415 + // silent about the target or invent a reason (#306 review, issue 2).
416 + // Only filled when still absent, so a tuner's own reason always wins.
417 + if ( isset( $context['target_score'] ) && ! isset( $out['stopped_because'] ) ) {
418 + $out['stopped_because'] = __( 'A target score was requested, but no iterative tuner is installed, so this was a single pass and the target was not chased.', 'xspeed' );
419 + }
420 +
421 + return $out;
422 + }
423 +
424 + /**
425 + * A page exercising a third template — the shop where WooCommerce is
426 + * active, otherwise the blog/posts archive, otherwise a category archive.
427 + *
428 + * Returns '' when none resolves; verify_urls() drops empties.
429 + */
430 + private static function third_template_url(): string {
431 + if ( function_exists( 'wc_get_page_id' ) ) {
432 + $shop = wc_get_page_id( 'shop' );
433 + if ( $shop > 0 ) {
434 + $link = get_permalink( (int) $shop );
435 + if ( is_string( $link ) && '' !== $link ) {
436 + return $link;
437 + }
438 + }
439 + }
440 +
441 + $posts_page = (int) get_option( 'page_for_posts' );
442 + if ( $posts_page > 0 ) {
443 + $link = get_permalink( $posts_page );
444 + if ( is_string( $link ) && '' !== $link ) {
445 + return $link;
446 + }
447 + }
448 +
449 + $terms = get_terms(
450 + array(
451 + 'taxonomy' => 'category',
452 + 'number' => 1,
453 + 'orderby' => 'count',
454 + 'order' => 'DESC',
455 + 'hide_empty' => true,
456 + )
457 + );
458 + if ( is_array( $terms ) && ! empty( $terms[0] ) && ! is_wp_error( $terms[0] ) ) {
459 + $link = get_term_link( $terms[0] );
460 + if ( is_string( $link ) && '' !== $link ) {
461 + return $link;
462 + }
463 + }
464 +
465 + return '';
466 + }
467 +
468 + /**
469 + * A short list of pages worth looking at after changes land.
470 + *
471 + * Verification sampled ONE url — the home page — because that is enough to
472 + * catch a fatal. It is not enough to catch a broken template: combining CSS
473 + * or deferring JS can leave the front page perfect and wreck a single post,
474 + * an archive, or a shop page, because those load handles the home page
475 + * never enqueued.
476 + *
477 + * So this returns the sampled page plus a couple of pages that exercise
478 + * DIFFERENT templates. Kept to three: a list long enough to feel like
479 + * homework gets skipped, and the point is that someone actually looks.
480 + *
481 + * @param string $sampled The URL verification already sampled.
482 + * @return array<int,string>
483 + */
484 + private static function verify_urls( string $sampled ): array {
485 + $urls = array( $sampled );
486 +
487 + // A single post exercises the post template and its assets, which is
488 + // where combine/defer breakage usually shows first.
489 + $posts = get_posts(
490 + array(
491 + 'numberposts' => 1,
492 + 'post_status' => 'publish',
493 + 'suppress_filters' => false,
494 + 'fields' => 'ids',
495 + )
496 + );
497 + if ( ! empty( $posts ) ) {
498 + $link = get_permalink( (int) $posts[0] );
499 + if ( is_string( $link ) && '' !== $link ) {
500 + $urls[] = $link;
501 + }
502 + }
503 +
504 + // A third template, and the one most likely to break differently: a
505 + // shop page loads WooCommerce's own handles, an archive loads the
506 + // theme's list template. Without this the list was always exactly two
507 + // — the docblock above promised a spread and named shop pages
508 + // specifically, and a WooCommerce site never saw one (#306 review,
509 + // issue 4).
510 + $urls[] = self::third_template_url();
511 +
512 + // Cap OUR OWN suggestions before filtering, not the filtered result.
513 + // The cap ran last, so on a site where three defaults already resolve
514 + // — any WooCommerce site — everything a filter added landed past the
515 + // limit and was silently dropped. The documented example is a shop's
516 + // checkout page: the template most likely to break when scripts are
517 + // combined, and the one the cap threw away. (#306 QA issue 4)
518 + //
519 + // Cleaned BEFORE capping, so the limit counts real, distinct pages.
520 + // third_template_url() returns '' when nothing resolves, and the
521 + // sampled URL can equal the shop or posts page — capping the raw list
522 + // let a placeholder or a duplicate burn a slot that nothing refills.
523 + $urls = array_slice( array_values( array_unique( array_filter( $urls ) ) ), 0, self::VERIFY_URL_LIMIT );
524 +
525 + /**
526 + * Filter the pages an optimize run asks the caller to check.
527 + *
528 + * A site whose risky template is a checkout, a login, or a builder
529 + * landing page knows that better than this does.
530 + *
531 + * Receives our suggestions already capped, and whatever it returns is
532 + * what the caller is asked to check — the filter is the site owner's
533 + * final say, so it is not re-capped afterwards. Return a short list:
534 + * the point is pages someone will actually open.
535 + *
536 + * @since 1.2.0
537 + *
538 + * @param array<int,string> $urls Suggested URLs, already capped.
539 + * @param string $sampled The URL verification sampled.
540 + */
541 + $urls = apply_filters( 'xspeed_optimize_verify_urls', $urls, $sampled );
542 +
543 + $clean = array();
544 + foreach ( (array) $urls as $u ) {
545 + $u = esc_url_raw( (string) $u );
546 + if ( '' !== $u && ! in_array( $u, $clean, true ) ) {
547 + $clean[] = $u;
548 + }
549 + }
550 +
551 + return $clean;
552 + }
553 +
554 + /**
555 + * The score to diagnose against, honouring the caller's measure_score.
556 + *
557 + * `never` is the pre-1.2.0 behaviour and stays available for callers that
558 + * genuinely must not spend a measurement. `auto` measures only when the
559 + * stored score is stale AND the cooldown allows it, which is what makes a
560 + * post-run score mean what a reader assumes it means.
561 + *
562 + * @param string $measure One of auto|never|always.
563 + * @param bool $assume_stale Treat the stored score as stale regardless
564 + * of its age — the post-apply path, where
565 + * changes just landed. Does NOT spend the
566 + * cooldown: only an explicit `always` does
567 + * that. (#306 QA issue 1)
568 + * @return array<string,mixed>|null
569 + */
570 + private static function score_for( string $measure, bool $assume_stale = false ): ?array {
571 + if ( 'never' === $measure ) {
572 + return Optimize_Diagnosis::latest_score();
573 + }
574 + return Optimize_Diagnosis::measure_fresh( 'always' === $measure, $assume_stale );
575 + }
576 +
577 + /**
181 578 * One sentence the assistant can lead with.
182 579 *
183 580 * Written so the honest outcomes read as outcomes rather than failures. A
184 581 * site where nothing was left to do is not a disappointing result, but
@@ -186,10 +583,13 @@
186 583 * that alone will either apologise or invent a win.
187 584 *
188 585 * @param array<string,mixed> $diagnosis From Optimize_Diagnosis::build().
189 586 * @param int $applied How many changes landed.
587 + * @param int $planned How many changes are WAITING —
588 + * non-zero only on a preview, where
589 + * nothing is applied by definition.
190 590 */
191 - private static function summary( array $diagnosis, int $applied ): string {
591 + private static function summary( array $diagnosis, int $applied, int $planned = 0 ): string {
192 592 $score = $diagnosis['score']['score'] ?? null;
193 593 $next = count( $diagnosis['agent_fixable'] );
194 594 $human = count( $diagnosis['human_fixable'] );
195 595
@@ -200,17 +600,54 @@
200 600 /* translators: %d: number of settings changed */
201 601 _n( 'Applied %d change.', 'Applied %d changes.', $applied, 'xspeed' ),
202 602 $applied
203 603 );
604 + } elseif ( $planned > 0 ) {
605 + // A preview applies nothing BY DEFINITION, so "nothing was
606 + // applied" must not be read as "nothing needed applying". This
607 + // sentence is what an assistant relays to the owner, and it
608 + // previously opened with "everything is already on" directly above
609 + // a list of ten changes it would make — so the assistant reported
610 + // a fully optimised site while the work sat waiting.
611 + // (#306 QA issue 3)
612 + $parts[] = sprintf(
613 + /* translators: %d: number of changes a real run would make */
614 + _n(
615 + 'Preview only — %d change would be applied.',
616 + 'Preview only — %d changes would be applied.',
617 + $planned,
618 + 'xspeed'
619 + ),
620 + $planned
621 + );
204 622 } else {
205 623 $parts[] = __( 'Everything that can be turned on safely is already on.', 'xspeed' );
206 624 }
207 625
208 626 if ( null !== $score ) {
627 + // The age is not a footnote. This sentence is what an assistant
628 + // reads back to the user, and "score: 77" after a run that just
629 + // finished says the run produced it. Naming when it was measured
630 + // is the difference between a report and a claim.
631 + $age = $diagnosis['score']['age_seconds'] ?? null;
632 +
633 + if ( null === $age ) {
634 + $when = __( 'date unknown', 'xspeed' );
635 + } elseif ( $age < 5 * MINUTE_IN_SECONDS ) {
636 + $when = __( 'measured just now', 'xspeed' );
637 + } else {
638 + $when = sprintf(
639 + /* translators: %s: human-readable duration, e.g. "2 hours" */
640 + __( 'measured %s ago', 'xspeed' ),
641 + human_time_diff( time() - $age, time() )
642 + );
643 + }
644 +
209 645 $parts[] = sprintf(
210 - /* translators: %d: last recorded performance score */
211 - __( 'Last recorded score: %d.', 'xspeed' ),
212 - (int) $score
646 + /* translators: 1: performance score, 2: when it was measured */
647 + __( 'Score: %1$d (%2$s).', 'xspeed' ),
648 + (int) $score,
649 + $when
213 650 );
214 651 }
215 652
216 653 if ( $next > 0 ) {