Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/OverviewStatistics extension in Invision Community 5

A core/OverviewStatistics extension adds a tile to the statistics dashboards an administrator sees under ACP → Statistics → Key Statistics. There are two such dashboards — "User Activity" (app=core&module=stats&controller=overview) and "Content Activity" (app=core&module=activitystats&controller=overview) — and a third page, "Saved Reports" (app=core&module=overview&controller=mycharts), where an admin can pin individual tiles into a named report and download the whole report as CSV. The tile is a small card with a title, an optional description, a body of HTML you generate, an optional per-tile filter form, and an optional auto-refresh. The date range at the top of the page is chosen by the admin and handed to you; you decide what the number means.

Every tile on those pages is one of these extensions. The registration count, the online-users chart, the reputation tables, the per-content-type activity counts and the forums "% solved" tile are all OverviewStatistics implementations, so there is no privileged core path a third-party app cannot reach.

The contract

The abstract is system/Extensions/OverviewStatisticsAbstract.php. Three methods are abstract; the rest have working defaults you may override.

namespace IPS\Extensions;

abstract class OverviewStatisticsAbstract
{
    /* Which of the two dashboards the tile appears on. The ONLY values core
       tests for are 'user' and 'activity'. Anything else renders nowhere. */
    public string $page	= 'user';

    /* REQUIRED. The sub-block keys this one class provides. One class may
       produce many tiles; core loops this and calls the other methods once
       per entry. Returning an empty array means the class contributes
       nothing and is skipped. */
    abstract public function getBlocks(): array;

    /* REQUIRED. Metadata for one tile. Return an EMPTY ARRAY to hide it. */
    abstract public function getBlockDetails( ?string $subBlock = NULL ): array;

    /* REQUIRED. The tile body, as a string of HTML. Loaded over AJAX, not
       on the initial page render. */
    abstract public function getBlock( array|string|null $dateRange = NULL, ?string $subBlock = NULL ): string;

    /* OPTIONAL but see below - the default return of NULL puts an error
       message in the CSV export instead of your numbers. */
    public function getBlockNumbers( array|string $dateRange = null, string $subBlock = null ) : array|null

    /* OPTIONAL. Add fields to the tile's filter dropdown. Only reached when
       getBlockDetails() returned 'form' => TRUE. */
    protected function _blockForm( Form &$form, ?string $subBlock = null, array $values=[] ) : void

    /* Helper. Maps '7', '30', '90', '180', '365' to a DateInterval,
       anything else to NULL. */
    protected static function getInterval( ?int $dateRange ) : ?DateInterval

    /* Provided by the abstract. Do not override unless you know why. */
    public function getSavedBlockFilters() : array
    public function getBlockForm( string $subBlock = NULL ): string|null
}

Signature lines above are copied from system/Extensions/OverviewStatisticsAbstract.php lines 42, 50, 58, 67, 75, 106, 118, 129 and 177. Note that core's own implementations declare the softer string $subBlock = NULL rather than ?string $subBlock = NULL — for example applications/core/extensions/core/OverviewStatistics/Registrations.php:55. Both satisfy PHP's variance rules, so either form works.

What getBlockDetails() must return

An associative array. The keys core actually reads are:

  • title — a language key, not a title. Both dashboard templates run it through addToStack() before display.
  • description — a language key or NULL. overviewStatisticBlock.phtml only renders the description block when this is truthy, so NULL means "no subtitle".
  • app — the directory name of an installed application. It becomes data-app on the tile and, on the Content Activity page, drives the per-app show/hide checkboxes.
  • refresh — an integer. ips.stats.overviewBlock.js:28 reads it as data-refresh and line 177 uses it as setInterval(..., this.refreshInterval * 1000), so the unit is seconds.
  • formTRUE to give the tile a filter dropdown, which then calls your _blockForm(). Omitted or FALSE means no filter icon. Use the boolean, not a truthy stand-in: overviewStatisticBlock.phtml tests $details['form'] === TRUE strictly, while getBlockForm() tests it loosely (@$details['form']), so 1 or 'yes' gives you a reachable endpoint with no icon to reach it from.

Returning an empty array is the supported way to hide a tile conditionally. applications/core/extensions/core/OverviewStatistics/Tags.php:61 does exactly this when the tags system is switched off:

return Settings::i()->tags_enabled ? array( 'app' => 'core', 'title' => 'stats_overview_toptags', 'description' => null, 'refresh' => 60 ) : [];

What $dateRange contains

The docblock on the abstract describes three shapes, and all three really occur:

  • NULL — all time. This is what you get when the admin picks "All Time", or when the request carries no range at all. It is not the default: the predate select is created with a default of '7' (applications/core/modules/admin/stats/overview.php:91), and the JS sends that value verbatim, so a fresh page load gives you the string '7', not NULL.
  • A string of days: '7', '30', '90', '180' or '365', taken straight from Request::i()->range. Pass it to static::getInterval() to get a DateInterval. The "All Time" option in the form submits range=0 (ips.stats.liveDateFilter.js:111 copies the predate select's value), and because PHP evaluates the string '0' as falsy, the if( Request::i()->range ) gate in loadBlock() and in getBlockForm() skips it — so "All Time" reaches you as NULL, not as '0'. You should still guard against a NULL interval if you ever call getInterval() with a value you did not get from that form, since it returns NULL for anything outside the five listed keys.
  • An array with start and end, both IPS\DateTime objects, built in applications/core/modules/admin/stats/overview.php:143-146 from the custom date picker.

The CSV export path builds the array form only. mycharts::_getDateRange() always returns [ 'start' => DateTime, 'end' => DateTime ], so if your code only handles the string form your numbers will silently be wrong in the download.

Who calls it

Four consumers, all in the AdminCP. All of them resolve extensions through Application, which keys every extension as {appDirectory}_{ExtensionName} (system/Application/Application.php:419). The two dashboards and the two AJAX endpoints pass the sort arguments — Application::allExtensions( 'core', 'OverviewStatistics', TRUE, 'core', 'Registrations' ) — but Chart::getSavedBlocks() calls Application::allExtensions( 'core', 'OverviewStatistics', TRUE ) with no sort, and mycharts::saveBlock() uses the per-app $application->extensions( 'core', 'OverviewStatistics', true ) instead. Do not rely on a fixed ordering.

  • applications/core/modules/admin/stats/overview.php:105 (manage()) renders the User Activity page shell. The template applications/core/dev/html/admin/stats/overview.phtml:10-12 filters on $block->page == 'user', then calls getBlocks() and getBlockDetails() for each sub-block. It never calls getBlock().
  • applications/core/modules/admin/activitystats/overview.php:60 does the same for $block->page == 'activity'. That class is class overview extends StatsOverview (line 35), so it inherits everything below. Its template additionally calls \IPS\Application::load( $details['app'] ) to build the app filter checkboxes.
  • applications/core/modules/admin/stats/overview.php:116 (loadBlock()) is the AJAX endpoint that actually calls getBlock( $dateFilters, Request::i()->subblock ) at line 155 and writes the return value straight to the response with Output::i()->sendOutput(). The User Activity page and the Saved Reports page point at module=stats&controller=overview&do=loadBlock; Content Activity points at module=activitystats&controller=overview&do=loadBlock, which is the same inherited method. loadBlockForm() at line 163 is the sibling endpoint for the filter dropdown; it calls getBlockForm() at line 173, which in turn calls your _blockForm(). The filter icon always links to the module=stats copy of that route.
  • applications/core/sources/Statistics/Chart.php:296-303 (Chart::getSavedBlocks()) reads the core_saved_overview_stats table, re-resolves each saved row to an extension, checks the saved sub-block is still in getBlocks(), and calls getBlockDetails(). Note that it then overwrites $details['title'] with the admin's saved stat_title, so on Saved Reports your language key is not used. mycharts::_compileBlocks() at line 785 then calls getBlockNumbers() (line 804) for the CSV download.

Registration is a file plus an entry in your app's data/extensions.json, under the core owner key. The forums app's entry is one line:

{
    "core": {
        "OverviewStatistics": {
            "Solved": "IPS\\forums\\extensions\\core\\OverviewStatistics\\Solved"
        }
    }
}

Failure modes

Most of the ways this extension point goes wrong are silent. A few are fatal for the whole page, not just your tile.

The tile simply is not there, and nothing is logged

This is by far the most common outcome, and it has at least five separate causes that all look identical.

The class name in extensions.json does not resolve. Application::extensions() at system/Application/Application.php:926 reads:

if( !is_string( $classname ) or !class_exists( $classname ) )
{
    /* Switching between branches confuses extensions */
    continue;
}

A single-backslash namespace in the JSON, a filename that does not match the class, or a parse error in your file all end here. No exception, no log entry.

Your constructor threw. Application::constructExtensionClass() at system/Application/Application.php:478-499 wraps new $classToUse( ... ) in catch( RuntimeException | OutOfRangeException $e ){} and returns null. If your constructor does something like Application::load( 'someapp' ) or Settings::i()->whatever in a way that raises either of those, your extension is dropped from the array with no trace. Any other exception type from the constructor is not caught and will take the whole page down instead — see below.

$page is not exactly 'user' or 'activity'. The two templates test for those literals. A typo, or leaving the Developer Center default unchanged when you meant the other page, produces a tile that renders on neither dashboard. It will still appear on Saved Reports if an admin ever managed to pin it, because savedStatBlocks.phtml does not filter on page at all.

getBlockDetails() returned an empty array. Intentional for conditional tiles, but easy to hit by accident: Solved.php:67-80 uses a switch on $subBlock and falls through to return []; at line 80. If getBlocks() and getBlockDetails() ever disagree about the sub-block keys, the tile disappears.

The datastore is stale. Application::allExtensions() caches the entire extension list in Store::i()->extensions, keyed by extension type, and only rebuilds when the key is absent (system/Application/Application.php:359-443). Installing or upgrading an application calls Store::i()->clearAll(), so a normal install is fine; dropping files in by hand during development is not. The Developer Center's extensions screen clears it (applications/core/modules/admin/developer/extensions.php:313), as does clearing the system cache.

A related and less obvious consequence of that cache: the per-member access filter is applied while the list is being built and the filtered result is what gets stored. Lines 406-442 skip any application for which $application->canAccess( ... ) is false, then write the survivors to Store::i()->extensions. canAccess() (system/Application/Application.php:5299) returns TRUE early in the AdminCP only for admins holding the core/applications/app_manage restriction; otherwise it falls through to the app's front-end disabled_groups. So an app that is hidden from the admin's own member group can be excluded from the cached list. We have read this in the source but have not reproduced the cross-request effect on a live install, so treat the practical impact as unverified; the code path itself is not in doubt.

A missing 'title' key takes down the entire statistics page

The dashboard templates do this, unguarded:

{{$details['title'] = member.language()->addToStack( $details['title'] );}}

Lang::addToStack() is declared public function addToStack( string $key, ?bool $vle=TRUE, array $options=array() ): string (system/Lang/Lang.php:875). The parameter is a non-nullable string, so passing the NULL that an undefined array key evaluates to raises a TypeError. Nothing catches it. The admin sees a fatal error on the whole User Activity or Content Activity page, and because the message names core's template rather than your class, the natural first assumption is that core is broken.

The same shape of problem applies to app on the Content Activity page: activitystats/overview.phtml:13-14 reads {{if $details['app'] && !isset( $apps[ $details['app'] ] )}} and then calls \IPS\Application::load( $details['app'] ) with no try. The guard is a truthiness test only — it saves you if the key is missing or empty, but not if it is a non-empty string. So an app value naming an application that is not installed raises an uncaught OutOfRangeException and kills the page. Returning your own app's directory is safe; returning something you guessed is not.

Missing 'app' or 'refresh' fails quietly instead

Warnings do not become exceptions in IPS 5. IPS::errorHandler() at init.php:797-800 returns immediately for E_WARNING, E_NOTICE, E_STRICT and E_DEPRECATED. So an undefined refresh key renders as data-refresh='', the JavaScript at ips.stats.overviewBlock.js:28 sets refreshInterval to false, and the tile just never auto-updates. An undefined app key renders data-app='', which means the Content Activity page's app checkboxes cannot show or hide your tile — it stays visible when the admin unticks everything. Neither produces an error message anywhere.

Not implementing getBlockNumbers() prints an accusation in the CSV

The abstract's default implementation returns NULL (OverviewStatisticsAbstract.php:106-109). mycharts::_compileBlocks() handles that at line 801:

if ( is_callable( array( $block['extension'], 'getBlockNumbers' ) ) )
{
    // Allow extensions to define their own numbers
    $numbersFound = $block['extension']->getBlockNumbers( $dateRange, $block['subblock'] );
    if ( !is_array( $numbersFound ) )
    {
        $numbersFound = [[ Member::loggedIn()->language()->get( "statsreports_legacy_block" ) ]];
    }
}

Because the abstract always declares the method, is_callable() is always true and the else branch — which scrapes data-number attributes out of your rendered HTML — is unreachable for anything extending OverviewStatisticsAbstract. The language string it substitutes is defined in applications/core/dev/lang/stats.php:248:

"statsreports_legacy_block" => "This block cannot be downloaded. Contact the third party author for more information.",

So an admin who adds your tile to a report and downloads the CSV gets a column containing that sentence, with your tile's title above it. There is no error and no log entry; the first you hear of it is the support ticket. Implement getBlockNumbers() and return an array, even a trivial one.

The array shape is loose. Core convention is [ 'statsreports_current_count' => n, 'statsreports_previous_count' => n ], where the keys are language keys used as CSV column suffixes. String keys become named columns; integer keys are numbered "Title - (1)", "Title - (2)" and so on. Tags.php:85 returns a plain tag => count map instead, which also works.

Anything getBlock() throws is returned as the tile body

loadBlock() ends with Output::i()->sendOutput( $block->getBlock( $dateFilters, Request::i()->subblock ) ) and has no try. An exception from your getBlock() is handled by the global exception handler, which in the AdminCP shows the message. Because the tile is an AJAX fragment, the error HTML is injected into the small card, often clipped or unreadable. If your tile shows a truncated error box while the rest of the page is fine, look at uncaught_exception in the system log rather than at the page.

Also note that $subBlock arrives from Request::i()->subblock — it is user input on that route, not something core validates against your getBlocks(). Core's own implementations defend themselves; Solved.php:93 and ContentActivity.php:103 both begin with an in_array( $subBlock, ... ) check and return '' if it fails. Do the same, particularly if you interpolate the sub-block key into a query or use it as a class name.

A complete working example

applications/core/extensions/core/OverviewStatistics/Registrations.php is the smallest real implementation in the suite and the one core sorts first on the two dashboards (they pass it as the $firstExtensionKey argument; Saved Reports does not sort). It is reproduced here in full, minus the licence header.

<?php
namespace IPS\core\extensions\core\OverviewStatistics;

use IPS\DateTime;
use IPS\Db;
use IPS\Extensions\OverviewStatisticsAbstract;
use IPS\Theme;
use function defined;
use function is_array;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

class Registrations extends OverviewStatisticsAbstract
{
	public string $page	= 'user';

	public function getBlocks(): array
	{
		return array( 'registrations' );
	}

	public function getBlockDetails( string $subBlock = NULL ): array
	{
		/* Description can be null and will not be shown if so */
		return array( 'app' => 'core', 'title' => 'stats_overview_registrations', 'description' => null, 'refresh' => 30 );
	}

	public function getBlock( array|string $dateRange = NULL, string $subBlock = NULL ): string
	{
		$numbers = $this->getBlockNumbers( $dateRange, $subBlock );
		return Theme::i()->getTemplate( 'stats' )->overviewComparisonCount( $numbers['statsreports_current_count'], $numbers['statsreports_previous_count'] );
	}

	public function getBlockNumbers( array|string $dateRange = NULL, string $subBlock=NULL ) : array
	{
		$where			= NULL;
		$previousCount	= NULL;

		if( $dateRange !== NULL )
		{
			if( is_array( $dateRange ) )
			{
				$where = array(
					array( 'joined > ?', $dateRange['start']->getTimestamp() ),
					array( 'joined < ?', $dateRange['end']->getTimestamp() ),
				);
			}
			else
			{
				$currentDate	= new DateTime;
				$interval = static::getInterval( $dateRange );
				$initialTimestamp = $currentDate->sub( $interval )->getTimestamp();
				$where = array( array( 'joined > ?', $initialTimestamp ) );

				$previousCount = Db::i()->select( 'COUNT(*)', 'core_members', array( array( 'joined BETWEEN ? AND ?', $currentDate->sub( $interval )->getTimestamp(), $initialTimestamp ) ) )->first();
			}
		}

		$count = Db::i()->select( 'COUNT(*)', 'core_members', $where )->first();
		return [
			'statsreports_current_count' => $count,
			'statsreports_previous_count' => $previousCount,
		];
	}
}

Two details worth copying. First, getBlock() delegates to getBlockNumbers() rather than duplicating the query, which is what keeps the CSV and the tile in agreement — Solved.php does duplicate it, and its own docblock at line 184 apologises for that. Second, overviewComparisonCount is a core template (applications/core/dev/html/admin/stats/overviewComparisonCount.phtml, parameters $count, $previousCount=NULL, $chart=FALSE) that draws the large number with an up/down delta. overviewCount takes a single $count; overviewTable takes $values, $previousValues=array() and draws a two-column table. From a third-party app, fetch them explicitly: Theme::i()->getTemplate( 'stats', 'core', 'admin' ), which is what the Developer Center stub (applications/core/data/defaults/extensions/OverviewStatistics.txt) generates. The bare getTemplate( 'stats' ) in the example above works because Theme::getTemplate() (system/Theme/Theme.php:871) defaults $app to Dispatcher::i()->application->directory and $location to Dispatcher::i()->controllerLocation — not from the calling class. Since every route that reaches this extension point is app=core in the admin location, a bare call from your app would also land on core's admin stats group; the argument for being explicit is that it is the only way to reach your own templates, and the only form that does not silently depend on the dispatcher.

Your title and description keys must exist in your app's dev/lang.php. If they do not, Lang::replaceWords() falls back to printing the key itself, so the tile heading reads stats_overview_myapp_thing in plain text.

Adding a filter dropdown

Set 'form' => TRUE in getBlockDetails() and implement _blockForm(). Core builds the Form object, submits it back to do=loadBlockForm, and on submission calls your getBlock() again (OverviewStatisticsAbstract.php:257) with the filter values still present in Request::i(). The extra "save these filters as the default" button is only added when Request::i()->saved_block_id is set, i.e. on Saved Reports — on the two dashboards there is no saved row, so getSavedBlockFilters() returns [] and the $values argument you receive is empty. You never read the form values from $form->values() yourself; you read them from the request inside getBlock(). Solved.php:314 is the whole implementation:

protected function _blockForm( Form &$form, ?string $subBlock = null, array $values=[] ) : void
{
	/* Make sure someone isn't trying to manipulate the request or do something weird */
	if( !in_array( $subBlock, $this->getBlocks() ) )
	{
		throw new DomainException;
	}

	$default = @$values[Forum::$nodeTitle] ? explode( ',', $values[Forum::$nodeTitle] ) : NULL;
	$form->add( new Node( Forum::$nodeTitle, $default, false, array( 'class' => '\IPS\forums\Forum', 'multiple' => TRUE, 'clubs' => FALSE ) ) );
}

Throwing DomainException is the documented way to refuse: getBlockForm() catches it and returns null, which means the dropdown opens empty. The abstract's own comment at line 192 is explicit that any other exception is deliberately left uncaught — "there is an intentional possible uncaught ex. to adhere to fail fast; it's an implementation issue in that case". The $values array holds raw request parameters previously saved to core_saved_overview_stats.stat_filters, not the formatted values $form->values() would give you, which is why Solved has to explode() a comma-separated string back into an array.

Verified against

Read from Invision Community 5.0.19 source (applications/core/data/versions.json, last entry 5001908 => "5.0.19"). Files inspected: system/Extensions/OverviewStatisticsAbstract.php; all fifteen core implementations under applications/core/extensions/core/OverviewStatistics/; applications/forums/extensions/core/OverviewStatistics/Solved.php, applications/blog/extensions/core/OverviewStatistics/Blogs.php, applications/downloads/extensions/core/OverviewStatistics/Downloads.php, applications/nexus/extensions/core/OverviewStatistics/Products.php and Subscriptions.php; the consumers applications/core/modules/admin/stats/overview.php, applications/core/modules/admin/activitystats/overview.php, applications/core/modules/admin/overview/mycharts.php and applications/core/sources/Statistics/Chart.php; Application::allExtensions(), Application::extensions(), Application::constructExtensionClass() and Application::canAccess() in system/Application/Application.php; IPS::errorHandler() in init.php; system/Lang/Lang.php; the templates stats/overview.phtml, activitystats/overview.phtml, reports/overviewStatisticBlock.phtml and reports/savedStatBlocks.phtml; the controller ips.stats.overviewBlock.js; the stub at applications/core/data/defaults/extensions/OverviewStatistics.txt; and applications/core/data/acpmenu.json. cms, calendar and gallery ship no OverviewStatistics extension.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.