Files
RenewCMS/features/activity_screen.feature

671 lines
52 KiB
Gherkin
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Feature: Activity screen, details slide-over and My activity
As a verified RenewCMS user
I want /activity to read as a plain-language timeline of who did what and when, with filters,
a summary strip, CSV export and a details panel for each event
So that an owner can audit the site without reading a log, and any other role sees its own history
Background:
Given /activity is a verified route (meta.auth "verified", meta.title "Activity") rendered as
ActivityView (views/ActivityView.vue, the "ActivityPage" of docs/design/components.md) inside
DashboardShell, per docs/design/routes.md and docs/design/screens/activity.md
And the activity API already exists (features/activity-log-backend.feature and
features/activity-log-producers.feature) and this slice wires the SPA to it unchanged, with
no Go change
And the Home card and the Settings "Retention" card of the same board are not in this slice
And where docs/design prose and the board frame ActivityFrame.dc.html differ, the frame is
followed
And the views read the session role from GET /v1/auth/me: "owner" and "admin" are the full
view; any other or absent value is the regular-user view ("My activity"), which no real
account can reach today and is covered by Vitest and one visual state only
And a full view loads, in parallel, GET /v1/activity?range=7d&page=1&size=25 and GET
/v1/activity/summary?range=7d; a regular-user view loads only GET /v1/activity with the same
query and never calls /summary or /export, and never sends an actor parameter
And the list query always sends range, page and size explicitly, and adds q, actor, category,
outcome, security, from and to only when set (outcome "all" is not sent, from and to only
with range=custom); the summary query sends only range, from and to; the export query sends
the list query without page and size
And filters and the page live in component state, not in the URL, and reset when the user
leaves /activity
And useApi's global handling covers 401 and the unverified 403
(features/api_client_global_error_handling.feature); this screen maps every other failure
And DashboardShell's navigation gains "Activity" (icon i-lucide-history, linking to /activity)
between "Users" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
Articles, Media, Users, Activity, Integration, Settings in that order, for every verified
user; "Activity" has aria-current="page" on /activity; the specs that list the destinations
are amended accordingly (named in the QA file)
And the PageHeader breadcrumb and the mobile title read the page title: "Activity", or
"My activity" for the regular-user view
And the page padding is 24 px on desktop and 16 px on mobile with a 16 px gap between the
header, the strip, the filter bar, the chips, the table and the pager
And the header is the title "Activity" (regular user: "My activity") with the subtitle "Who
did what, and when, across your site." (regular user: "Everything you've done on this
site."), and, in the full view except Forbidden, an outline button with icon download and
the label "Export" and a trailing chevron at the right, opening the menu described in activity_filter_cards_and_export.feature, aligned to the bottom of the title block
And sentences are composed in the SPA from the event's action code, its outcome, its params
and its target name through the existing i18n (English keys, French in messages.ts), never
stored as text; the target name is user content, is inserted untranslated as a semibold
segment, and is the last slot of every template, so a missing target name drops the slot
with its leading space and colon
And the success sentences are, in English and French (the actor sits in its own column, so
sentences start with the verb; {0} is the target name):
| action | English | French |
| article.create | Created the article {0} | Article créé : {0} |
| article.edit | Edited the article {0} | Article modifié : {0} |
| article.publish | Published the article {0} | Article publié : {0} |
| article.unpublish | Unpublished the article {0} | Article dépublié : {0} |
| article.delete | Deleted the article {0} | Article supprimé : {0} |
| media.upload | Uploaded the image {0} | Image téléversée : {0} |
| media.rename | Renamed the image {0} | Image renommée : {0} |
| media.delete | Deleted the image {0} | Image supprimée : {0} |
| media.merge | Merged duplicates into the image {0} | Doublons fusionnés dans l'image {0} |
| media.purge | Purged duplicate images: {0} merged, {1} articles updated | Doublons purgés : {0} fusionnés, {1} articles mis à jour |
| system.media_purge | Ran the scheduled duplicate purge: {0} merged, {1} articles updated | Purge automatique des doublons : {0} fusionnés, {1} articles mis à jour |
| user.register | Registered the account {0} | Compte créé : {0} |
| user.verify | Verified the account {0} | Compte vérifié : {0} |
| user.update_profile | Updated the profile of {0} | Profil mis à jour : {0} |
| invite.create | Invited {0} | Invitation envoyée à {0} |
| invite.cancel | Cancelled the invite for {0} | Invitation annulée : {0} |
| invite.renew | Renewed the invite for {0} | Invitation renouvelée : {0} |
| invite.accept | Accepted the invite for {0} | Invitation acceptée : {0} |
| api_key.create | Created API key {0} | Clé d'API créée : {0} |
| api_key.revoke | Revoked API key {0} | Clé d'API révoquée : {0} |
| auth.login | Logged in | Connexion |
| auth.logout | Logged out | Déconnexion |
| auth.password_reset_requested | Requested a password reset | Réinitialisation du mot de passe demandée |
| auth.password_reset_completed | Reset the password | Mot de passe réinitialisé |
| system.update_checked | Checked for updates | Recherche de mises à jour |
| system.update_downloaded | Downloaded the update {0} | Mise à jour téléchargée : {0} |
| system.restart_requested (kind restart) | Requested a restart | Redémarrage demandé |
| system.restart_requested (kind update) | Restarted to apply the update {0} | Redémarrage pour appliquer la mise à jour {0} |
| system.activity_retention_changed | Changed the activity retention from {0} to {1} | Durée de conservation de l'activité modifiée : {0} → {1} |
| system.activity_cleanup | Removed {0} old events from the activity log | {0} anciens événements supprimés du journal d'activité |
And the failed sentences, used when outcome is "failed", are:
| action | English | French |
| article.create | Failed to create the article {0} | Échec de la création de l'article {0} |
| article.edit | Failed to edit the article {0} | Échec de la modification de l'article {0} |
| article.publish | Failed to publish the article {0} | Échec de la publication de l'article {0} |
| article.unpublish | Failed to unpublish the article {0} | Échec de la dépublication de l'article {0} |
| article.delete | Failed to delete the article {0} | Échec de la suppression de l'article {0} |
| media.upload | Failed to upload the image {0} | Échec du téléversement de l'image {0} |
| media.rename | Failed to rename the image {0} | Échec du renommage de l'image {0} |
| media.delete | Failed to delete the image {0} | Échec de la suppression de l'image {0} |
| media.merge | Failed to merge duplicates into the image {0} | Échec de la fusion des doublons dans l'image {0} |
| media.purge | Failed to purge duplicate images | Échec de la purge des doublons |
| system.media_purge | Failed to run the scheduled duplicate purge | Échec de la purge automatique des doublons |
| user.register | Failed to register the account {0} | Échec de la création du compte {0} |
| user.verify | Failed to verify the account {0} | Échec de la vérification du compte {0} |
| user.update_profile | Failed to update the profile of {0} | Échec de la mise à jour du profil de {0} |
| invite.create | Failed to invite {0} | Échec de l'invitation de {0} |
| invite.cancel | Failed to cancel the invite for {0} | Échec de l'annulation de l'invitation de {0} |
| invite.renew | Failed to renew the invite for {0} | Échec du renouvellement de l'invitation de {0} |
| invite.accept | Failed to accept the invite for {0} | Échec de l'acceptation de l'invitation de {0} |
| api_key.create | Failed to create API key {0} | Échec de la création de la clé d'API {0} |
| api_key.revoke | Failed to revoke API key {0} | Échec de la révocation de la clé d'API {0} |
| auth.login_failed | Failed login attempt for {0} | Échec de connexion pour {0} |
| system.update_checked | Failed to check for updates | Échec de la recherche de mises à jour |
| system.update_downloaded | Failed to download the update {0} | Échec du téléchargement de la mise à jour {0} |
| system.restart_requested | Failed to restart | Échec du redémarrage |
And an action whose failed sentence is not listed (for example auth.login) renders its success
sentence with the Failed badge; an action code that is not in the catalogue renders the raw
code (for example "article.archive") and never throws
And for auth.login_failed the slot {0} is the actor's name (the attempted username, since the
event has no target), while the Actor column reads "Not signed in"
And counts in params are shown with the locale's number format; "{0} old events" and "{1}
articles updated" use the singular form for 1 ("1 old event", "1 article updated"); a
media.purge or system.media_purge event with params.failed_groups appends ", {2} groups
failed" ("1 group failed") in English and ", {2} groupes en échec" in French;
system.activity_retention_changed shows days as "{n} days" and 0 as "Forever" ("{n} jours",
"Toujours")
And the category icons are: articles file-text, media image, users users, api_keys key-round,
auth shield-check, system server-cog; the category labels are Articles, Media, Users and
invites, API keys, Authentication, System
And the Actor cell is the actor's name with: a UAvatar of initials (first letters of the first
two words, upper case) for type "user"; a key-round icon avatar and an "API key" badge for
"api_key"; a cog icon avatar and a "System" badge for "system"; a user-round-x icon avatar
and the text "Not signed in" for "anonymous"
And the Target cell is "—" (muted) when target is null; when target.exists it is the name as an
underlined link with an arrow-up-right icon, plus the target type as a muted second line
(Article, Image, User, Invite, API key, Release); when it does not exist the name is struck
through, muted, with a "Deleted" badge (icon trash-2) and the type line; the link goes to
/article/{id}/edit for article, /media for image, /users for user and invite,
/integration/api-keys for api_key, and a target type with no page (release) is plain text
And the Outcome cell is a badge with icon and word, never colour alone: "Succeeded" (check,
bordered) or "Failed" (circle-x, --rc-error-soft background, --ui-error text)
And a row whose event has security true shows a shield-alert icon in --ui-warning after the
sentence and a 2 px inset --ui-warning edge on its left side; on mobile it also shows a
"Security" badge (shield-alert, --rc-warning-soft, --ui-warning)
And the table (md and up) has the columns When, Actor, Action, Target, Outcome in a grid
96px 210px minmax(0,1fr) 230px 120px (regular user: When, Action, Target, Outcome in 96px
minmax(0,1fr) 230px 120px), a 36 px header on --ui-bg-elevated with an arrow-down icon on
When, day separator rows of 32 px on --ui-bg-muted with the label in 12 px semibold, and
rows at least 52 px high with a top border; a selected row (details open) and a hovered row
use --ui-bg-elevated
And rows are grouped by the user's local calendar day under a separator labelled "Today",
"Yesterday" or the date as "3 Oct 2026" (French "3 oct. 2026"), in the order the API
returns them (newest first); the When cell shows the time as drawn, relative within today
("2 min ago", "29 min ago", "1 h ago") and the local clock time "HH:mm" on earlier days,
and carries the full local time "3 Oct 2026, 14:20:05" as its title attribute
And below md the table is replaced by a stacked list of cards (ActivityCardList) with the same
separators: a first line with the actor (avatar, name, badge; hidden for the regular user)
and the time at the right, the category icon and the sentence (15 px, up to 3 lines), then a
line of badges: the Outcome badge, "Security" when flagged, and "Target deleted" when the
target is gone; each card is at least 44 px high as a touch target
And rows and cards are focusable, open the details on click and on Enter, and a click on the
target link navigates to the target without opening the details
And the summary strip (ActivitySummaryStrip, full view only) has four cards that are real
buttons: "Total actions", "Active people", "Failed actions", "Security events", each with
the number (locale-formatted), a sub-line, and a seven-bar sparkline of seven div bars,
4 px wide, 2 px apart, oldest at the left, bar height round(value / max(series, 1) * H)
with a 2 px minimum and H of 20 px on desktop and 16 px on mobile, drawn from
series.total, series.active, series.failed and series.security
And on desktop the strip is a 4-column grid with a 12 px gap; below md it is, as drawn in
ActivityFrame.dc.html, a single row of 156 px cards with an 8 px gap that is clipped at the
page edge
And the Total sub-line is "{pct}% vs previous 7 days" with icon trending-up when total is at
least previous_total and trending-down otherwise, pct being the rounded absolute change
against previous_total; for the ranges Today, 30 days and Custom the words read "vs previous
day", "vs previous 30 days" and "vs previous period"; when previous_total is 0 the sub-line
is "No earlier data" and when the two totals are equal it is "No change", both without an
icon (French "Aucune donnée antérieure", "Aucun changement")
And the Active people number is active_people.users plus active_people.api_keys with the
sub-line "{users} people, {api_keys} API keys" ("1 person", "1 API key")
And the Failed sub-line is "{pct}% of all actions" (one decimal, "0.5%") with icon circle-x
and the --rc-error-soft background and --ui-error number
And the Security sub-line is "{n} flagged" (French "{n} signalés", "1 signalé"), n being
summary.security, with icon shield-alert and the --rc-warning-soft background and
--ui-warning number
And a Failed or Security count of 0 reads "0" with "Nothing failed" or "Nothing to flag" and
icon circle-check in --ui-success, drops the emphasis fill and draws its bars in
--ui-border-accented; with total 0 Total and Active people read "0" with "No data yet"
And the strip follows only the date range; q, person, category, outcome and security never
change it, and it is not requested again when they change
And when the summary request fails with any status or never reaches the server, the strip is
not rendered and nothing is shown in its place
And the filter bar (ActivityFilterBar) has: a UInput (icon search, clearable, placeholder
"Search activity", 240 px) that applies 300 ms after the last keystroke or at once on Enter;
a Person USelectMenu (icon user-round, "All people", 168 px, full view only) with the groups
users, API keys and "System"; a Category USelectMenu (icon layout-list, "All categories",
176 px); an Outcome button group "All / Succeeded / Failed" (soft is the selected one,
outline the others); and the date presets "Today / 7 days / 30 days / Custom" (Custom has
icon calendar and opens a date-range popover) at the right; controls are 32 px high
And the Person options come from GET /v1/user/ (accepted users, shown by display name or
username) and GET /v1/api-key/, requested once, the first time the Person menu opens, so a
page load makes no other call than the two above
And choosing a Person sends actor=user:{id}, actor=key:{id} or actor=system; a Category sends
category=articles|media|users|api_keys|auth|system; Outcome Succeeded sends outcome=success
and Failed outcome=failed; Today, 7 days and 30 days send range=today|7d|30d; Custom sends
range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD once both dates are chosen; any filter change
resets the page to 1 and sends a new list request, and the page size is kept
And the active filter chips (UBadge subtle with a trailing ghost x button) read "Search:
spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Security
events", "Dates: 30 days" or "Dates: 1 – 30 Sep 2026" (same month "1 – 30 Sep 2026", across
months "28 Sep – 3 Oct 2026"), followed by a link button "Clear all"; the default range 7
days is never a chip; removing a chip resets that filter; "Clear all" resets every filter to
the default (range 7d)
And below md the bar is a search field and a "Filters" button (icon sliders-horizontal) with a
count badge (the number of chips, hidden at 0); it opens a bottom sheet titled "Filters"
with Person (full view only), Category, Outcome, Dates (two date fields when Custom is
chosen) and a footer "Clear all" (disabled at 0 filters) and "Show {total} results" ("Show 1
result"); the sheet applies each change at once, so the footer count is the current total,
and "Show … results" closes the sheet
And the pager (full width under the table) reads "Showing {from}–{to} of {total}" with
locale-formatted numbers, a UPagination (previous, 1, 2, 3, …, last, next; previous
disabled on page 1), and "Rows per page" with a select of 25, 50 and 100; changing the size
resets the page to 1; below md it reads "Page {page} of {pages}" above "{from}–{to} of
{total}", with previous and next buttons and the size select
And while a list request is in flight for longer than 150 ms, 9 skeleton rows (desktop) or 5
skeleton cards (below md) replace the rows and the pager is hidden; the strip shows 4
skeleton cards while the summary request is in flight for longer than 150 ms; nothing is
drawn in their place for the first 150 ms
And the list is "empty" when total is 0: with no chip it is "No activity yet" (icon history,
"Actions on articles, media, users and API keys will appear here.", regular user: "Your
actions will appear here once you start working on the site."), with at least one chip it is
"No activity matches these filters" (icon search-x, action "Clear filters")
And the error, empty and forbidden panels are drawn as in ActivityFrame.dc.html: "Couldn't
load activity" is an error alert with icon circle-x, the text and an outline "Try again"
button, and "You can't view this activity" (no trailing period) uses icon lock and the
action "Back to home"
And "Export" is disabled in "No activity yet" and while the list is in the Error state,
and enabled otherwise
And ActivityDetailsPanel is a USlideover: 480 px wide from the right on desktop, an 88 %-high
bottom sheet with a 36×4 px handle below md; its header reads "Activity details" with a
close button (and "{position} of {total}" below md); it opens from a row or card, closes
with Escape, the close button or the scrim, and focus returns to the row that opened it
And the panel body has, in this order: the full sentence (18 px semibold, target in semibold)
with the Outcome badge, a "Security" badge when flagged and the category icon and label; an
error alert "Why it failed" with failure_reason, only when outcome is "failed" and the
reason is present; the grid When, Actor, Source, IP address, Browser; a Target section;
a Changes section
And When shows the local time as "6 Oct 2026, 10:42:18 (your time)" and under it, muted, the
UTC time as "6 Oct 2026, 10:42:18 UTC"; Actor is the avatar, the name and the same badges
as the table; Source is "Web app" for web, "API key" for api_key and "System task" for
system; IP address (IBM Plex Mono 12 px) and Browser read "Not applicable" (muted) when ip
or user_agent is null
And the Target section shows the name (a link with arrow-up-right when it exists, otherwise
struck through with a "Deleted" badge and the line "This item was deleted. Its name is kept
for reference."), and the line "{type} · {id}"; it is absent when target is null
And the Changes section (ActivityDiff) is absent when changes is empty; each change is a row
headed by its field label: a "text" change shows Before and After boxes (side by side with
an arrow-right on desktop, stacked with an arrow-down below md), a "body" change shows only
the line "Body edited (+312 characters)" with icon file-pen-line ("-48", "+1 character")
and never any text
And the field labels are title "Title", online "Publish state", body "Body", filename "File
name", display_name "Display name" (French "Titre", "État de publication", "Corps", "Nom du
fichier", "Nom d'affichage"), an unknown field shows its raw code, and for online the
values "false" and "true" read "Draft" and "Published" ("Brouillon", "Publié")
And a Before or After value taller than two lines is clamped to two lines and a "Show more"
link button appears under that row; it toggles to "Show less"; shorter values show no toggle
And the panel footer has "Previous event" (icon chevron-up) and "Next event" (icon
chevron-down) buttons that move through the current filtered list, and on desktop the
muted text "Event {position} of {total}" with position = (page − 1) × size + index + 1;
below md the two buttons are full width and there is no footer text
And when Previous or Next crosses a page boundary the list page follows, and a failed request
for the neighbour page keeps the panel on the current event with no message
Scenario: Navigation — "Activity" sits between Users and Integration
Given the user is on /activity
When DashboardShell mounts
Then the sidebar and the mobile drawer list Home, Articles, Media, Users, Activity,
Integration, Settings in that order
And "Activity" has the history icon, links to /activity and has aria-current="page"
And the breadcrumb reads "Activity" (mobile title "Activity")
Scenario: Default — an owner sees the strip, the filter bar and a day-grouped table
Given GET /v1/activity resolves 200 with 25 items and total 1284, and GET /v1/activity/summary
resolves 200 with total 1284, previous_total 1146, active_people {users 7, api_keys 2},
failed 6, security 11 and seven-day series, and the session role is "owner"
When the user navigates to /activity
Then the header shows "Activity", "Who did what, and when, across your site." and an enabled
"Export"
And the strip reads "1,284" "12% vs previous 7 days" with trending-up; "9" "7 people, 2 API
keys"; "6" "0.5% of all actions"; "11" "11 flagged"
And the filter bar shows "Search activity", "All people", "All categories", All selected in
the Outcome group, and 7 days selected, with no chip
And the table rows follow the API order under day separators
And the pager reads "Showing 1–25 of 1,284" and "Rows per page" 25
And GET /v1/activity was requested once with range=7d, page=1, size=25 and no other
parameter, and GET /v1/activity/summary once with range=7d
Scenario Outline: Rows — the Actor cell and the sentence follow the event
Given a row for an event with actor type "<type>" named "<name>", action "<action>", outcome
"<outcome>" and security <security>
Then the Actor cell shows <actor>
And the Action cell shows "<sentence>" with the target name in semibold, and <marker>
And the Outcome cell shows "<badge>"
Examples:
| type | name | action | outcome | security | actor | sentence | marker | badge |
| user | Marta Alves | article.publish | success | false | an avatar "MA" and "Marta Alves" | Published the article Spring release | no shield | Succeeded |
| api_key | Website sync | media.upload | success | false | a key icon, "Website sync", "API key" | Uploaded the image north-wall.jpg | no shield | Succeeded |
| system | System | system.media_purge | success | false | a cog icon, "System", "System" | Ran the scheduled duplicate purge: 3 merged, 5 articles updated | no shield | Succeeded |
| anonymous | alex@example.com | auth.login_failed | failed | true | a user-round-x icon, "Not signed in" | Failed login attempt for alex@example.com | a shield-alert marker | Failed |
| user | Inès Duarte | article.publish | failed | false | an avatar "ID" and "Inès Duarte" | Failed to publish the article Spring release | no shield | Failed |
| user | Jonas Weber | api_key.revoke | success | true | an avatar "JW" and "Jonas Weber" | Revoked API key Mobile app | a shield-alert marker | Succeeded |
Scenario: Rows — a deleted target is struck through, a living one is a link
Given an event of target {"type": "image", "id": "9", "name": "old-banner.png", "exists": false}
and an event of target {"type": "article", "id": "8", "name": "Spring release", "exists": true}
Then the first Target cell shows "old-banner.png" struck through with a "Deleted" badge and
"Image"
And the second Target cell shows "Spring release" as a link to /article/8/edit and "Article"
And clicking that link navigates to /article/8/edit and does not open the details
Scenario: Rows — an event with no target shows a dash
Given an event whose target is null
Then its Target cell shows a muted "—"
Scenario: Rows — an unknown action code renders the code
Given an event with action "article.archive"
Then its Action cell shows "article.archive" and the row still opens the details
Scenario Outline: Sentences — English and French come from the same code and params
Given an event with action "<action>", outcome "<outcome>", params <params> and target
"<target>"
When the language is <language>
Then the sentence reads "<sentence>" with "<target>" untranslated
Examples:
| action | outcome | params | target | language | sentence |
| article.publish | success | {} | Spring release | English | Published the article Spring release |
| article.publish | success | {} | Spring release | French | Article publié : Spring release |
| article.publish | failed | {} | Spring release | French | Échec de la publication de l'article Spring release |
| media.purge | success | {"merged": 3, "articles_updated": 5} | | English | Purged duplicate images: 3 merged, 5 articles updated |
| media.purge | success | {"merged": 3, "articles_updated": 5, "failed_groups": 1} | | English | Purged duplicate images: 3 merged, 5 articles updated, 1 group failed |
| system.activity_cleanup | success | {"removed": 1} | | English | Removed 1 old event from the activity log |
| system.activity_retention_changed | success | {"from": 90, "to": 0} | | English | Changed the activity retention from 90 days to Forever |
| system.restart_requested | success | {"kind": "update", "tag": "v1.4.0"} | v1.4.0 | English | Restarted to apply the update v1.4.0 |
| media.upload | failed | {} | | English | Failed to upload the image |
Scenario: Loading — skeletons after 150 ms while both requests are in flight
Given GET /v1/activity and GET /v1/activity/summary have not resolved
When the user navigates to /activity
Then the header, the filter bar and an enabled "Export" are drawn at once
And nothing is drawn in place of the strip and the table for the first 150 ms
And after 150 ms 4 skeleton cards replace the strip and 9 skeleton rows (5 cards below md)
replace the table, the pager is hidden and the table container is aria-busy="true"
When both requests resolve 200 with the Default data
Then the skeletons are replaced by the Default state's strip, rows and pager
Scenario: Zero-value summary strip — a quiet period shows a calm 0
Given GET /v1/activity/summary resolves 200 with total 214, previous_total 223, active_people
{users 4, api_keys 1}, failed 0, security 0 and series of zeros for failed and security
And GET /v1/activity resolves 200 with 25 items
When the user navigates to /activity
Then the Total card reads "214" "4% vs previous 7 days" with trending-down
And the Active people card reads "5" "4 people, 1 API key"
And the Failed card reads "0" "Nothing failed" with circle-check and no error fill
And the Security card reads "0" "Nothing to flag" with circle-check and no warning fill
And their bars are 2 px high
Scenario Outline: The Total sub-line when there is nothing to compare
Given GET /v1/activity/summary resolves 200 with total <total> and previous_total <previous>
When the user navigates to /activity
Then the Total card reads "<total>" and "<subline>" without a trend icon
Examples:
| total | previous | subline |
| 40 | 0 | No earlier data |
| 40 | 40 | No change |
Scenario: Empty, no activity yet — a new site
Given GET /v1/activity resolves 200 with items [] and total 0, and GET /v1/activity/summary
resolves 200 with every number 0
And no filter chip is shown
When the user navigates to /activity
Then the strip reads "0" "No data yet" for Total and Active people, "0" "Nothing failed" and
"0" "Nothing to flag"
And an empty panel with icon history shows "No activity yet" and "Actions on articles,
media, users and API keys will appear here."
And "Export" is disabled and no table header or pager is shown
Scenario: Empty, filters match nothing — the panel offers Clear filters
Given the Default state has loaded
When the user types "sitemap" in Search, chooses Category "Media" and Outcome "Failed", and
GET /v1/activity resolves 200 with items [] and total 0
Then the chips read "Search: sitemap", "Category: Media", "Outcome: Failed" and "Clear all"
And an empty panel with icon search-x shows "No activity matches these filters" and "Try a
different search or a wider date range." with the action "Clear filters"
And "Export" stays enabled and the strip is unchanged
When the user activates "Clear filters"
Then every filter is reset, GET /v1/activity is sent with range=7d, page=1, size=25 only and
the rows come back
Scenario Outline: Error with retry — a 500, a 400 or a network failure replaces the table
Given GET /v1/activity <failure> and GET /v1/activity/summary resolves 200
When the user navigates to /activity
Then the strip and the filter bar are shown
And an error alert shows "Couldn't load activity" and "Check your connection and try again."
with the action "Try again"
And "Export" is disabled and no table or pager is shown
When the user activates "Try again"
Then the Loading state shows for the list and GET /v1/activity is sent again with the same
query
When it then resolves 200 with the Default data
Then the Default rows replace the alert
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 400 response |
| never reaches the server |
Scenario Outline: The summary alone fails — the strip is not drawn and the list still works
Given GET /v1/activity resolves 200 with the Default items and GET /v1/activity/summary
<failure>
When the user navigates to /activity
Then the strip is not rendered, with no error shown for it
And the filter bar, the rows and the pager are shown as in Default
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Forbidden (403) — the page itself is refused
Given GET /v1/activity resolves 403 and GET /v1/auth/me resolves 200 with a verified user
When the user navigates to /activity
Then the header shows "Activity" and no "Export"
And no strip, filter bar, chips, table or pager is shown
And an empty panel with icon lock shows "You can't view this activity" and "Your role
doesn't include this page. Ask an owner or admin if you need access." with the action "Back
to home"
When the user activates "Back to home"
Then the router navigates to /home
Scenario: An unverified 403 and a 401 are handled globally
Given GET /v1/activity resolves 401 (or 403 for an unverified session)
When the user navigates to /activity
Then useApi redirects to /login?redirect=/activity&reason=expired (or to /register/pending)
And no Forbidden or Error panel is shown
Scenario Outline: Strip cards — each card applies its filter
Given the Default state has loaded with the 7 days range
When the user activates the "<card>" card
Then <effect>
And <state>
Examples:
| card | effect | state |
| Total actions | every filter is cleared and GET /v1/activity is sent with range=7d | no card is marked active |
| Active people | nothing changes and no request is sent | no card is marked active |
| Failed actions | GET /v1/activity is sent with outcome=failed and the page 1 | the chip "Outcome: Failed" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border |
| Security events | GET /v1/activity is sent with security=true and the page 1 | the chip "Security events" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border |
Scenario: Filtered, card applied — the Failed card on top of other filters
Given the user has chosen Category "Authentication" and 30 days
When the user activates the "Failed actions" card and GET /v1/activity resolves 200 with 1
item and total 6
Then the chips read "Category: Authentication", "Outcome: Failed", "Dates: 30 days" and
"Clear all"
And GET /v1/activity/summary was requested for range=30d and is not requested again
And the pager reads "Showing 1–6 of 6"
And below md the "Filters" button shows the count badge "3"
Scenario Outline: Filter bar — each control sends its query parameter and resets the page
Given the user is on page 3 of the Default list
When <action>
Then GET /v1/activity is sent with <query>, page=1 and size=25
Examples:
| action | query |
| the user types "spring" and waits 300 ms | q=spring and range=7d |
| the user types "spring" and presses Enter at once | q=spring and range=7d |
| the user chooses Person "Marta Alves" (user 3) | actor=user:3 and range=7d |
| the user chooses Person "Website sync" (API key 7) | actor=key:7 and range=7d |
| the user chooses Person "System" | actor=system and range=7d |
| the user chooses Category "Users and invites" | category=users and range=7d |
| the user chooses Outcome "Succeeded" | outcome=success and range=7d |
| the user chooses "Today" | range=today |
| the user chooses "30 days" | range=30d |
Scenario: Filter bar — Custom opens a date-range popover and applies on the second date
Given the Default state has loaded
When the user chooses "Custom" and picks 1 Sep 2026 then 30 Sep 2026 in the popover
Then GET /v1/activity and GET /v1/activity/summary are sent with range=custom, from=2026-09-01
and to=2026-09-30
And the chip reads "Dates: 1 – 30 Sep 2026"
And no request is sent after the first date alone
Scenario: Chips — removing one resets only that filter, Clear all resets everything
Given the chips "Search: spring", "Outcome: Failed" and "Dates: 30 days" are shown
When the user activates the x of "Outcome: Failed"
Then GET /v1/activity is sent with q=spring and range=30d and without outcome
When the user activates "Clear all"
Then GET /v1/activity is sent with range=7d only, and the summary is requested for range=7d
And no chip and no "Clear all" is shown
Scenario: The Person menu loads its options once, on first open
Given the Default state has loaded
Then GET /v1/user/ and GET /v1/api-key/ have not been requested
When the user opens the Person menu
Then both are requested once and the menu lists the users, then the API keys, then "System"
And opening it again does not request them again
Scenario: Pagination — next page, page size and totals
Given the Default state has loaded with total 1284
When the user activates page "2" of the pager
Then GET /v1/activity is sent with page=2 and size=25 and the pager reads "Showing 26–50 of
1,284"
When the user chooses "50" in "Rows per page"
Then GET /v1/activity is sent with page=1 and size=50 and the pager reads "Showing 1–50 of
1,284"
And on the last page 52 of size 25 the pager reads "Showing 1,276–1,284 of 1,284" and next is
disabled
Scenario: Mobile — the list is cards and the controls collapse into a sheet
Given the Default state has loaded below md with the filters Category "Authentication" and
Outcome "Failed" applied and GET /v1/activity resolves with total 6
Then the page shows the search field and "Filters" with the badge "2", and no Person,
Category, Outcome or date control
And the table is replaced by cards, each with its actor, time, sentence and badges
And the pager reads "Page 1 of 1" with "1–6 of 6"
When the user activates "Filters"
Then a bottom sheet titled "Filters" shows Person, Category, Outcome, Dates, "Clear all" and
"Show 6 results"
When the user chooses "Custom"
Then two date fields appear in the sheet
When the user activates "Show 6 results"
Then the sheet closes and the list stays filtered
Scenario: Details — a row opens the slide-over and focus returns on close
Given the Default state has loaded
When the user activates the second row (click, or Enter with the row focused)
Then the slide-over opens with the title "Activity details", the row is marked selected and
the footer reads "Event 2 of 1,284"
When the user presses Escape (or activates the close button, or the scrim)
Then the slide-over closes and focus is on that row
Scenario: Details — the Edit with a diff event
Given the event "Why we reuse original floorboards" has action "article.edit" with changes
[{"field": "title", "kind": "text", "before": "Why we reuse floorboards", "after": "Why we
reuse original floorboards"}, {"field": "body", "kind": "body", "delta": 312}]
When its details open
Then the sentence reads "Edited the article Why we reuse original floorboards"
And the Changes section shows a "Title" row with Before "Why we reuse floorboards" and After
"Why we reuse original floorboards", then a "Body" row with "Body edited (+312 characters)"
And no body text appears anywhere in the panel
Scenario: Details — long values clamp to two lines with Show more
Given an event with a text change whose Before and After are 170-character values, and
another text change with short values
When its details open and the long values overflow two lines
Then the long row's values are clamped and "Show more" appears under that row only
When the user activates "Show more"
Then the values are fully shown and the link reads "Show less"
Scenario: Details — a failed action with a very long target title shows its reason
Given an event with action "article.publish", outcome "failed", failure_reason "The article
has no cover image. Add one, then publish again." and a 150-character target name
When its details open
Then the sentence wraps with the title in semibold, the "Failed" badge is shown and an error
alert "Why it failed" reads the reason
And the Target section wraps the long name
Scenario: Details — a deleted target has no diff
Given an event of action "media.delete" with target {"type": "image", "id": "9", "name":
"old-banner.png", "exists": false} and no changes
When its details open
Then the Target section shows "old-banner.png" struck through, a "Deleted" badge and "This
item was deleted. Its name is kept for reference."
And there is no Changes section
Scenario: Details — a system-initiated event has no human actor
Given an event with actor {"type": "system", "id": null, "name": "System"}, source "system",
ip null and user_agent null
When its details open
Then Actor shows the cog avatar, "System" and a "System" badge, Source reads "System task",
and IP address and Browser read "Not applicable"
Scenario: Details — a failed login shows the actor as not signed in
Given an event with action "auth.login_failed", actor {"type": "anonymous", "id": null,
"name": "alex@example.com"}, security true, outcome "failed", failure_reason "Wrong username
or password.", ip "198.51.100.77" and user_agent "Chrome 129 on Windows"
When its details open
Then the sentence reads "Failed login attempt for alex@example.com" with a "Security" badge
and a "Failed" badge
And Actor reads "Not signed in", the alert "Why it failed" shows the reason, and the IP
address and the Browser are shown
Scenario: Details — the times are shown in local time and in UTC
Given an event created_at "2026-10-06T10:42:18Z"
When its details open
Then the When line ends with "(your time)" and the muted line below reads "6 Oct 2026,
10:42:18 UTC"
Scenario Outline: Details — Source reads the event's source
Given an event with source "<source>"
When its details open
Then Source reads "<label>"
Examples:
| source | label |
| web | Web app |
| api_key | API key |
| system | System task |
Scenario: Details — Previous and Next move through the filtered list across pages
Given the list is on page 1 of size 25 with total 1284 and the first row's details are open
Then "Previous event" is disabled and the footer reads "Event 1 of 1,284"
When the user activates "Next event" 24 times
Then the footer reads "Event 25 of 1,284" with no extra request
When the user activates "Next event" once more
Then GET /v1/activity is sent with the same filters, page=2 and size=25
And the panel shows the first item of page 2, the footer reads "Event 26 of 1,284" and the
pager under the table reads "Showing 26–50 of 1,284"
When the user activates "Previous event"
Then GET /v1/activity is sent with page=1 and the panel shows the last item of page 1 as
"Event 25 of 1,284"
And on the last event "Next event" is disabled
Scenario: Details — a failed neighbour page keeps the panel on the current event
Given the last item of page 1 is open
When the user activates "Next event" and GET /v1/activity resolves 500
Then the panel still shows the same event, the button is enabled again and the list is
unchanged
Scenario: Details — below md the slide-over is a bottom sheet
Given the Default state has loaded below md
When the user activates the second card
Then a bottom sheet opens, 88 % high, with a handle, the title "Activity details", "2 of
1,284" in the header, and full-width "Previous event" and "Next event" buttons
And there is no "Event 2 of 1,284" footer text
Scenario: My activity — a role that is not owner or admin sees its own events
Given the session role is "editor" (or absent) and GET /v1/activity resolves 200 with 25
items and total 41
When the user navigates to /activity
Then the title reads "My activity", the subtitle "Everything you've done on this site." and
the breadcrumb "My activity"
And no summary strip, no Person filter and no "Export" are rendered
And the table has the columns When, Action, Target, Outcome and no Actor column
And the cards show no actor line below md
And GET /v1/activity is sent with range=7d, page=1, size=25 and no actor parameter, and
GET /v1/activity/summary is never sent
And the pager reads "Showing 1–25 of 41"
Scenario: My activity, empty — the user has not done anything yet
Given the session role is "editor" and GET /v1/activity resolves 200 with items [] and total 0
When the user navigates to /activity
Then an empty panel with icon history shows "No activity yet" and "Your actions will appear
here once you start working on the site."
Scenario: My activity, details — the slide-over on one of the user's own events
Given the session role is "editor" and the Default list has loaded
When the first row's details open
Then the footer reads "Event 1 of 41", "Previous event" is disabled and the panel is the same
as for an owner, with the Actor row showing the user's own name
Scenario: The French interface composes sentences and chrome in French
Given the language is French and the Default state has loaded as owner
Then the title reads "Activité", the strip labels, filter placeholders, badges, pager and
"Exporter" are in French, the sentences use the French templates and day
separators read "Aujourd'hui", "Hier" or "3 oct. 2026"
And the Security sub-line reads "11 signalés"
And every new English string of this screen has a French entry in messages.ts
Scenario: Hover shows the exact time
Given the Default state has loaded
Then each When cell has a title attribute holding the full local time with seconds