Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [3.2.1] - 07.06.2026

- Datumsbereich bleibt beim Wechsel zwischen Statistik-Tabs erhalten ([#88](https://github.com/FriendsOfREDAXO/statistics/issues/88))
- Weitere harte UI-Texte in Sprachdateien ausgelagert ([#109](https://github.com/FriendsOfREDAXO/statistics/issues/109))

## [3.2.0] - 29.03.2026

- Performance improvements with lazy loading
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
"matomo/network": "^2.0"
},
"replace": {
"composer/ca-bundle": "*",
"psr/log": "*",
"psr/container": "*",
"psr/http-message": "*",
"ankitpokhrel/tus-php": "*",
"symfony/deprecation-contracts": "*",
"symfony/service-contracts": "*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-intl-grapheme": "*",
Expand Down
389 changes: 89 additions & 300 deletions composer.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions lang/de_de.lang
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ statistics_scroll_panel = Container
statistics_scroll_none = Keines von beidem
statistics_statistics_ignore_url_params = Ignoriere URL-Parameter
statistics_statistics_ignore_url_params_note = URL-Parameter werden in der Statistik nicht geloggt. Z.b. www.example.com?data=1 wird als www.example.com gespeichert.
statistics_details_for = Details für:
statistics_all_domains = Alle Domains
statistics_filter_all = Alle
statistics_filter_only_200 = Nur 200er
statistics_filter_only_not_200 = Nur nicht 200er
statistics_ignore_success = Es wurden %s Einträge gelöscht.
statistics_ignore_url_future = Die URL <code>%s</code> wird zukünftig ignoriert.
statistics_deleted_campaign_entries = Es wurden %s Einträge der Kampagne <code>%s</code> gelöscht.
perm_general_statistics[] = Benutzer darf das Addon "Statistiken" aufrufen.
perm_options_statistics[settings] = Benutzer darf Einstellungen des Addons "Statistiken" ändern.
statistics_default_datefilter_range = Standard Datumsbereich
Expand Down
8 changes: 8 additions & 0 deletions lang/en_gb.lang
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ statistics_scroll_panel = Container
statistics_scroll_none = None of both
statistics_statistics_ignore_url_params = Ignore URL parameters.
statistics_statistics_ignore_url_params_note = URL parameters are not logged in the statistics. E.g. www.example.com?data=1 is stored as www.example.com.
statistics_details_for = Details for:
statistics_all_domains = All domains
statistics_filter_all = All
statistics_filter_only_200 = Only 200
statistics_filter_only_not_200 = Only non-200
statistics_ignore_success = %s entries were deleted.
statistics_ignore_url_future = The URL <code>%s</code> will be ignored in the future.
statistics_deleted_campaign_entries = %s entries of campaign <code>%s</code> were deleted.
perm_general_statistics[] = User may open the "Statistics" addon.
perm_options_statistics[settings] = User may change settings of the "Statistics" addon.
statistics_default_datefilter_range = Default date range.
Expand Down
97 changes: 64 additions & 33 deletions lib/DateFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use rex;
use rex_addon;
use rex_addon_interface;
use rex_request;
use rex_sql;
use rex_view;
use InvalidArgumentException;
Expand All @@ -22,8 +24,10 @@ class DateFilter

public DateTimeImmutable $whole_time_start;

/** @var non-empty-string */
private string $table;
private rex_addon $addon;
private rex_addon_interface $addon;
private const SESSION_KEY = 'statistics_datefilter';


/**
Expand All @@ -38,37 +42,62 @@ class DateFilter
*/
function __construct(string $date_start, string $date_end, string $table)
{
if ('' === $table) {
throw new InvalidArgumentException('Table name must not be empty.');
}

/** @var non-empty-string $table */
$this->table = $table;
$this->addon = rex_addon::get('statistics');

if ($date_start == '') {

// prefered date range
$date_range = $this->addon->getConfig('statistics_default_datefilter_range');

if ($date_range == 'last7days') {
$date = new DateTimeImmutable();
$date = $date->modify("-7 day");
$this->date_start = $date;
} elseif ($date_range == 'last30days') {
$date = new DateTimeImmutable();
$date = $date->modify("-30 day");
$this->date_start = $date;
} elseif ($date_range == 'thisYear') {
$date = new DateTimeImmutable();
$date = $date->modify("-365 day");
$this->date_start = $date;
} else {
$this->date_start = $this->getMinDateFromTable();
}
// design decision, uncomment this line to default show only timespan where data was collected
// $this->date_end = $this->getMaxDateFromTable();
$hasRequestDateRange = '' !== $date_start && '' !== $date_end;

$this->date_end = new DateTimeImmutable();
// $this->date_end->modify('+1 day');
} else {
if ($hasRequestDateRange) {
$this->date_start = new DateTimeImmutable($date_start);
$this->date_end = new DateTimeImmutable($date_end);

rex_request::setSession(self::SESSION_KEY, [
'date_start' => $this->date_start->format('Y-m-d'),
'date_end' => $this->date_end->format('Y-m-d'),
]);
} else {
$sessionDateRange = rex_request::session(self::SESSION_KEY);
$hasSessionDateRange = is_array($sessionDateRange)
&& isset($sessionDateRange['date_start'], $sessionDateRange['date_end'])
&& is_string($sessionDateRange['date_start'])
&& is_string($sessionDateRange['date_end'])
&& '' !== $sessionDateRange['date_start']
&& '' !== $sessionDateRange['date_end'];

if ($hasSessionDateRange) {
$this->date_start = new DateTimeImmutable($sessionDateRange['date_start']);
$this->date_end = new DateTimeImmutable($sessionDateRange['date_end']);
} else {

// prefered date range
$date_range = $this->addon->getConfig('statistics_default_datefilter_range');

if ($date_range === 'last7days') {
$date = new DateTimeImmutable();
$date = $date->modify('-7 day');
$this->date_start = $date;
} elseif ($date_range === 'last30days') {
$date = new DateTimeImmutable();
$date = $date->modify('-30 day');
$this->date_start = $date;
} elseif ($date_range === 'thisYear') {
$date = new DateTimeImmutable();
$date = $date->modify('-365 day');
$this->date_start = $date;
} else {
$this->date_start = $this->getMinDateFromTable();
}
// design decision, uncomment this line to default show only timespan where data was collected
// $this->date_end = $this->getMaxDateFromTable();

$this->date_end = new DateTimeImmutable();
// $this->date_end->modify('+1 day');
}
}

// set total time range to use in datefilter fragment with javascript
Expand All @@ -90,16 +119,18 @@ function __construct(string $date_start, string $date_end, string $table)
private function getMinDateFromTable(): DateTimeImmutable
{
$sql = rex_sql::factory();
$min_date = $sql->setQuery('SELECT MIN(date) AS "date" from ' . rex::getTable($this->table));
$min_date = $min_date->getValue('date');
$result = $sql->setQuery('SELECT MIN(date) AS "date" from ' . rex::getTable($this->table));
$minDateRaw = $result->getValue('date');

if ($min_date === null) {
$min_date = new DateTimeImmutable("now");
} else {
$min_date = DateTimeImmutable::createFromFormat('Y-m-d', $min_date);
if (!is_string($minDateRaw) || '' === $minDateRaw) {
return new DateTimeImmutable('now');
}

$minDate = DateTimeImmutable::createFromFormat('Y-m-d', $minDateRaw);
if (false === $minDate) {
return new DateTimeImmutable('now');
}

return $min_date;
return $minDate;
}
}
2 changes: 1 addition & 1 deletion package.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package: statistics
version: 3.2.0
version: 3.2.1
author: Andreas Lenhardt
supportpage: https://github.com/AndiLeni/statistics

Expand Down
8 changes: 4 additions & 4 deletions pages/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
echo StatsSubpageRenderer::renderFilter($current_backend_page, $filter_date_helper);


if ($request_name != '' && $delete_entry === true) {
if ($request_name !== '' && $delete_entry === true) {
$sql = rex_sql::factory();
$sql->setQuery('delete from ' . rex::getTable('pagestats_api') . ' where name = :name', ['name' => $request_name]);
echo rex_view::success('Es wurden ' . $sql->getRows() . ' Einträge der Kampagne <code>' . $request_name . '</code> gelöscht.');
echo rex_view::success(sprintf($addon->i18n('statistics_deleted_campaign_entries'), (string) $sql->getRows(), htmlspecialchars($request_name, ENT_QUOTES)));
}

// details section
if ($request_name != '' && !$delete_entry) {
if ($request_name !== '' && !$delete_entry) {
// details section for single campaign

$pagedetails = new EventDetails($request_name, $filter_date_helper);
Expand All @@ -36,7 +36,7 @@
$content = '<div id="chart_details" style="height:500px; width:auto"></div>';

echo StatsSubpageRenderer::renderInfoSection(
'Details für:',
$addon->i18n('statistics_details_for'),
$request_name,
$content . StatsChartConfig::renderScript('chart_details', StatsChartConfig::buildTimelineOption($sum_data['labels'], $sum_data['values']))
);
Expand Down
8 changes: 4 additions & 4 deletions pages/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
$filter_date_helper = new DateFilter($request_date_start, $request_date_end, 'pagestats_media');
echo StatsSubpageRenderer::renderFilter($current_backend_page, $filter_date_helper);

if ($request_url != '' && $delete_entry === true) {
if ($request_url !== '' && $delete_entry === true) {
$sql = rex_sql::factory();
$sql->setQuery('delete from ' . rex::getTable('pagestats_media') . ' where url = :url', ['url' => $request_url]);
echo rex_view::success('Es wurden ' . $sql->getRows() . ' Einträge der Kampagne <code>' . $request_url . '</code> gelöscht.');
echo rex_view::success(sprintf($addon->i18n('statistics_deleted_campaign_entries'), (string) $sql->getRows(), htmlspecialchars($request_url, ENT_QUOTES)));
}

// details section
if ($request_url != '' && !$delete_entry) {
if ($request_url !== '' && !$delete_entry) {
// details section for single campaign

$pagedetails = new MediaDetails($request_url, $filter_date_helper);
Expand All @@ -34,7 +34,7 @@
$content = '<div id="chart_details" style="height:500px; width:auto"></div>';

echo StatsSubpageRenderer::renderInfoSection(
'Details für:',
$addon->i18n('statistics_details_for'),
$request_url,
$content . StatsChartConfig::renderScript('chart_details', StatsChartConfig::buildTimelineOption($sum_data['labels'], $sum_data['values']))
);
Expand Down
20 changes: 12 additions & 8 deletions pages/pages.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,18 @@

// check if request is for ignoring a url
// if yes, add url to addon settings and delete all database entries of this url
if ($request_url != '' && $ignore_page === true) {
if ($request_url !== '' && $ignore_page === true) {
$rows = $pages_helper->ignorePage($request_url);
echo rex_view::success('Es wurden ' . $rows . ' Einträge gelöscht. Die Url <code>' . $request_url . '</code> wird zukünftig ignoriert.');
echo rex_view::success(
sprintf($addon->i18n('statistics_ignore_success'), (string) $rows)
. ' '
. sprintf($addon->i18n('statistics_ignore_url_future'), htmlspecialchars($request_url, ENT_QUOTES))
);
}


// details for one url requested
if ($request_url != '' && !$ignore_page) {
if ($request_url !== '' && !$ignore_page) {
// details section for single page

$pagedetails = new PageDetails($request_url, $filter_date_helper);
Expand All @@ -47,7 +51,7 @@
$content .= StatsChartConfig::renderScript('chart_details', StatsChartConfig::buildTimelineOption($sum_data['labels'], $sum_data['values']));
$content .= $pagedetails->getList();

echo StatsSubpageRenderer::renderInfoSection('Details für:', $request_url, $content);
echo StatsSubpageRenderer::renderInfoSection($addon->i18n('statistics_details_for'), $request_url, $content);
}


Expand All @@ -56,7 +60,7 @@
$domains = $sql->getArray('SELECT distinct domain FROM ' . rex::getTable('pagestats_visits_per_day'));
$domain_select = '
<select id="stats_domain_select" class="form-control">
<option value="">Alle Domains</option>
<option value="">' . htmlspecialchars($addon->i18n('statistics_all_domains'), ENT_QUOTES) . '</option>
';
foreach ($domains as $domain) {
$domain_select .= '<option value="' . $domain['domain'] . '">' . $domain['domain'] . '</option>';
Expand All @@ -69,9 +73,9 @@
$o2 = rex_context::fromGet()->getUrl(["httpstatus" => "200"]);
$on2 = rex_context::fromGet()->getUrl(["httpstatus" => "not200"]);

$http_filter_buttons = '<a class="btn btn-primary" href="' . $oa . '">Alle</a>
<a class="btn btn-primary" href="' . $o2 . '">Nur 200er</a>
<a class="btn btn-primary" href="' . $on2 . '">Nur nicht 200er</a>';
$http_filter_buttons = '<a class="btn btn-primary" href="' . $oa . '">' . htmlspecialchars($addon->i18n('statistics_filter_all'), ENT_QUOTES) . '</a>
<a class="btn btn-primary" href="' . $o2 . '">' . htmlspecialchars($addon->i18n('statistics_filter_only_200'), ENT_QUOTES) . '</a>
<a class="btn btn-primary" href="' . $on2 . '">' . htmlspecialchars($addon->i18n('statistics_filter_only_not_200'), ENT_QUOTES) . '</a>';


echo StatsSubpageRenderer::renderSection(
Expand Down
6 changes: 3 additions & 3 deletions pages/referer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
echo StatsSubpageRenderer::renderFilter($current_backend_page, $filter_date_helper);

// details for one url requested
if ($request_ref != '') {
if ($request_ref !== '') {
// details section for single page

$refererDetails = new RefererDetails($request_ref, $filter_date_helper);
$sum_data = $refererDetails->getSumPerDay();

echo StatsSubpageRenderer::renderInfoSection(
'Details für:',
$addon->i18n('statistics_details_for'),
$request_ref,
'<a target="_blank" href="' . htmlspecialchars($request_ref, ENT_QUOTES) . '">' . htmlspecialchars($request_ref, ENT_QUOTES) . '</a><div id="chart_details" style="height:500px; width:auto"></div>' . StatsChartConfig::renderScript('chart_details', StatsChartConfig::buildTimelineOption($sum_data['labels'], $sum_data['values'])) . $refererDetails->getList()
);
Expand All @@ -43,7 +43,7 @@
$table = rex_view::info($addon->i18n('statistics_no_data'));
} else {
$table = '<table class="table-bordered dt_order_second statistics_table table-striped table-hover table">';
$table .= '<thead><tr><th>Referer</th><th>' . htmlspecialchars($addon->i18n('statistics_count'), ENT_QUOTES) . '</th></tr></thead><tbody>';
$table .= '<thead><tr><th>' . htmlspecialchars($addon->i18n('statistics_referer'), ENT_QUOTES) . '</th><th>' . htmlspecialchars($addon->i18n('statistics_count'), ENT_QUOTES) . '</th></tr></thead><tbody>';

foreach ($refererRows as $row) {
$referer = (string) $row['referer'];
Expand Down
56 changes: 56 additions & 0 deletions update.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,62 @@

$addon = rex_addon::get('statistics');

$sql = rex_sql::factory();

$tableExists = static function (string $table): bool {
if ('' === $table) {
return false;
}

return rex_sql_table::get($table)->exists();
};

$deduplicateCountTable = static function (string $table, array $keyColumns, string $countColumn = 'count') use ($sql, $tableExists): void {
if (!$tableExists($table)) {
return;
}

$quotedKeys = array_map([$sql, 'escapeIdentifier'], $keyColumns);
$groupBy = implode(', ', $quotedKeys);
$columns = implode(', ', array_merge($quotedKeys, [$sql->escapeIdentifier($countColumn)]));

$duplicates = $sql->getValue(
'SELECT COUNT(*) FROM ('
. 'SELECT 1 FROM ' . $sql->escapeIdentifier($table)
. ' GROUP BY ' . $groupBy
. ' HAVING COUNT(*) > 1'
. ') AS duplicate_rows'
);

if ((int) $duplicates === 0) {
return;
}

$tempTable = $table . '_dedup_tmp';
$sql->setQuery('DROP TEMPORARY TABLE IF EXISTS ' . $sql->escapeIdentifier($tempTable));

$sql->setQuery(
'CREATE TEMPORARY TABLE ' . $sql->escapeIdentifier($tempTable) . ' AS '
. 'SELECT ' . $groupBy . ', SUM(' . $sql->escapeIdentifier($countColumn) . ') AS ' . $sql->escapeIdentifier($countColumn)
. ' FROM ' . $sql->escapeIdentifier($table)
. ' GROUP BY ' . $groupBy
);

$sql->setQuery('TRUNCATE TABLE ' . $sql->escapeIdentifier($table));
$sql->setQuery(
'INSERT INTO ' . $sql->escapeIdentifier($table)
. ' (' . $columns . ') '
. 'SELECT ' . $columns . ' FROM ' . $sql->escapeIdentifier($tempTable)
);
};

$deduplicateCountTable(rex::getTable('pagestats_visits_per_day'), ['date', 'domain']);
$deduplicateCountTable(rex::getTable('pagestats_visitors_per_day'), ['date', 'domain']);
$deduplicateCountTable(rex::getTable('pagestats_data'), ['type', 'name']);
$deduplicateCountTable(rex::getTable('pagestats_media'), ['url', 'date']);
$deduplicateCountTable(rex::getTable('pagestats_api'), ['name', 'date']);
$deduplicateCountTable(rex::getTable('pagestats_bot'), ['name', 'category', 'producer']);

// create tables
$addon->includeFile(__DIR__ . '/install.php');

Expand Down
Loading