Feature: Recent activity card on Home and Retention card on Settings As a RenewCMS user I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings So that I see what happened without opening the Activity page, and the log does not grow without bound Background: Given the activity API (features/activity-log-backend.feature, activity-log-producers.feature, activity_screen.feature) and the settings retention fields already exist And this slice adds one field to the settings API, activity_next_cleanup_at, and wires the SPA to the existing endpoints; there is no other Go change And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity") and docs/design/screens/settings.md ("Activity retention"), groups "Home" and "Settings: Retention" of RenewCMS Screens Back-office.dc.html And both boards draw the existing cards as dashed placeholders, so neither card is pixel-compared and Vitest is the SPA coverage And "owner" and "admin" are the full Home view; any other or absent session role is the regular-user view And every new English string has a French entry in messages.ts And GET /v1/settings (every verified user) and PATCH /v1/settings (owner only, behind RequireOwner) both answer with the JSON object {"version", "activity_retention_days", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case, nothing else And this supersedes the statement of features/settings_page.feature that the body is exactly {"version": ""}; the version field and the verified-user access of that feature are unchanged And activity_next_cleanup_at is an RFC 3339 timestamp with the server's offset, or null when activity_retention_days is 0 (Forever) And otherwise it is the next 03:00 (audit.CleanupHour) in the server's time zone strictly after the server's current time And the value is computed in the domain (audit.Settings) from the clock the use cases are built with, never in the handler and never in the SPA And the cleanup it announces is the existing daily job, which never touches the last 24 hours (audit.KeepRecentHours) And GetActivityRetentionUseCase and UpdateActivityRetentionUseCase both return the value, so a PATCH response carries the next cleanup of the new period (null after switching to Forever, a timestamp after leaving Forever) And the existing validation, 400 with the text of apperr.BodyErrorMessage for any value outside 0, 30, 90, 365 or a malformed body, and 403 for a non-owner are unchanged And the domain package stays free of GORM, HTTP and the clock source (the clock is passed in) And on /home a card "Recent activity" (RecentActivityCard) sits in a 420 px column to the right of the existing cards from the lg breakpoint up, with a 16 px gap, in a grid (minmax(0,1fr) 420px, items aligned to the start) that spans the full content width, with no max-width, as the board frame draws it And the greeting header and the update banner stay above the grid at full width And below lg the card is stacked above the stat tiles and the recent article list, below the banner, with a 16 px gap And the card is --ui-bg with a 1 px --ui-border and a header row (12px 16px padding, 1 px bottom border) holding the title (14 px semibold, --ui-text-highlighted) and, at the right, the link "View all" with an arrow-right icon (14 px; 44 px touch height below lg) to /activity And the title is "Recent activity", and "Your recent activity" in the regular-user view And on mount the card sends exactly one GET /v1/activity?size=5 with no other parameter and never an actor parameter; it never calls /summary or /export; the server returns own events only for a regular user And no range is sent, so the server's default of 7 days applies and a site quiet for more than a week reads "No activity yet" And the card loads independently of GET /v1/article/: neither waits for or blocks the other, and an error or empty card leaves the stat tiles, the recent article list, the articles' own empty and error states and "Connect your website" as they are And each row (up to 5, in API order) has a 24 px avatar, the sentence clamped to two lines with the target name in semibold (composed by ActivitySentence exactly as on /activity, never stored text), then a muted 12 px line: "{actor name} · {time}" in the full view and only "{time}" in the regular-user view And the avatar is the same as the Actor cell of /activity (initials for a user, key-round for an API key, cog for the system, user-round-x for an anonymous actor) And the time of an event of today is "{n} min ago" or "{n} h ago" (lib/activityTime), an event of yesterday reads "Yesterday, 14:20" and an older one "3 Oct 2026, 14:20" (French "Hier, 14:20", "3 oct. 2026, 14:20"); the full local time with seconds is its title attribute And a failed event shows a "Failed" badge at the right of its row; a successful event with security true shows a small shield icon (14 px, --ui-warning) and no badge; a failed security event shows the badge only And rows are not interactive; only "View all" navigates And while the request is in flight for longer than 150 ms the card body shows five skeleton rows (24 px circle, two bars) and aria-busy="true"; nothing is drawn in the body for the first 150 ms And on a 500, any other non-401 status or a network failure the body is an inline role="alert" error with icon circle-x, title "Couldn't load recent activity", no description, and a "Try again" button that sends the same GET /v1/activity?size=5 again; a 401 is handled globally by useApi And when total is 0 the body is the empty state: icon history, "No activity yet" and "Actions on your site will appear here." (regular user: "Your actions will appear here.") And on /settings, for the owner only, a third card (RetentionCard) follows UpdatesCard and MediaCleanupCard and has the same width as its sibling cards; for an admin, a regular user or an absent role it is not rendered at all And the card reads GET /v1/settings, the single call SettingsView already makes, whose typed response is widened with the four activity_* fields; the page still sends exactly one GET /v1/settings per navigation And the header is "Retention" (14 px semibold) over "How long activity is kept." (12 px muted) with a status area at the right, aria-live="polite": "Saving…" (loader-circle) while a PATCH is in flight, "Saved" (check, --ui-success) for 2 s after it succeeds And the body has the label "Keep activity for" over a USelectMenu (at most 260 px wide) with the items "30 days", "90 days", "365 days", "Forever" (values 30, 90, 365, 0) and a 12 px muted helper: "Older events are removed automatically." or, for Forever, "Nothing is removed automatically. The log grows over time." And an info UAlert with icon lock is always shown: "Activity logs are append-only" / "Events can't be edited or deleted from the app. Only the retention period removes them." And after a divider two read-only lines (120 px label then value; stacked on mobile) are shown in the user's local time: "Last cleanup" reads "6 Oct 2026, 03:00 · 142 events removed" ("1 event removed" for 1), or "Never" when activity_last_cleanup_at is null; "Next cleanup" reads "7 Oct 2026, 03:00" from activity_next_cleanup_at, or "Not scheduled" when it is null And for Forever the last-cleanup line still shows the real last cleanup when one exists, and "Never" otherwise And the periods are ordered 30 < 90 < 365 < Forever, and "shorter" means a lower rank than the saved period And choosing a longer period, including Forever, sends PATCH /v1/settings with {"activity_retention_days": } at once And choosing a shorter period sends nothing and shows a warning UAlert with icon triangle-alert, title "Events older than {n} days will be removed", text "This happens at the next cleanup on {date}. It can't be undone." (the date is the saved settings' activity_next_cleanup_at, formatted like the line above) and the buttons "Apply {n} days" and "Cancel" And a PATCH 200 returns the full settings, which refresh the select and the last and next lines And any non-401 PATCH failure (400, 403, 500, network) shows an error UAlert with icon circle-x, title "Couldn't save the retention period", text "Your previous setting is still in place." and a "Try again" button, reverts the select to the saved value and leaves the lines unchanged; a 403 additionally refreshes the session, after which the card is gone And "Try again" re-sends the failed value without asking for the shortening confirmation again And while GET /v1/settings is loading for longer than 150 ms the card shows skeleton lines with aria-busy="true" And when GET /v1/settings fails the card shows the Media cleanup card's alert, "Couldn't reach RenewCMS. Check your connection and try again." with "Try again", which re-runs the shared load And while the browser is offline the select, "Apply" and "Cancel" are disabled; the page's offline banner copy is unchanged Scenario Outline: Settings API, GET returns the next cleanup of the saved period Given the saved retention is days and the server time is in a server zone of +02:00 When a verified user sends GET /v1/settings Then the status is 200 And the body has "activity_next_cleanup_at" and the other four fields Examples: | days | now | next | | 90 | 2026-10-06T10:00:00+02:00 | "2026-10-07T03:00:00+02:00" | | 90 | 2026-10-06T02:59:59+02:00 | "2026-10-06T03:00:00+02:00" | | 90 | 2026-10-06T03:00:00+02:00 | "2026-10-07T03:00:00+02:00" | | 30 | 2026-12-31T23:30:00+02:00 | "2027-01-01T03:00:00+02:00" | | 365 | 2026-10-06T10:00:00+02:00 | "2026-10-07T03:00:00+02:00" | | 0 | 2026-10-06T10:00:00+02:00 | null | Scenario: Settings API, GET keeps the existing fields and adds exactly one Given the saved retention is 90 days, the last cleanup is 2026-10-06T01:00:00Z with 142 events removed When a verified user sends GET /v1/settings Then the body is a JSON object with exactly the keys version, activity_retention_days, activity_last_cleanup_at, activity_last_cleanup_removed and activity_next_cleanup_at Scenario: Settings API, GET is open to every verified user Given a verified admin or editor sends GET /v1/settings Then the status is 200 and the body includes activity_next_cleanup_at Scenario Outline: Settings API, PATCH returns the next cleanup of the new period Given the saved retention is days and the user is the owner When the owner sends PATCH /v1/settings with {"activity_retention_days": } Then the status is 200 and activity_retention_days is And activity_next_cleanup_at is Examples: | from | to | next | | 90 | 30 | the next 03:00 server time | | 90 | 0 | null | | 0 | 365 | the next 03:00 server time | | 90 | 90 | the next 03:00 server time | Scenario: Settings API, PATCH with the saved value changes nothing Given the saved retention is 90 days When the owner sends PATCH /v1/settings with {"activity_retention_days": 90} Then the status is 200, nothing is saved and no audit event is recorded Scenario: Settings API, PATCH by a non-owner is refused and changes nothing Given a verified admin When the admin sends PATCH /v1/settings with {"activity_retention_days": 30} Then the status is 403 with the text "Only the owner can do this." and the saved retention is unchanged Scenario Outline: Settings API, PATCH rejects invalid input When the owner sends PATCH /v1/settings with Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved Examples: | body | | {"activity_retention_days": 7} | | {"activity_retention_days": "30"} | | {} | | not json | Scenario: Settings use cases compute the next cleanup from their clock Given GetActivityRetentionUseCase and UpdateActivityRetentionUseCase are built with a fake settings repository and a fixed clock Then GetActivityRetention returns Settings whose NextCleanupAt is the next 03:00 after the clock, or nil for Forever And UpdateActivityRetention returns the new period with its own NextCleanupAt And UpdateActivityRetention(7) returns an error satisfying errors.Is(err, audit.ErrInvalidRetention) and saves nothing Scenario Outline: Home, owner or admin, the last five events with the actor name Given the session role is "" and GET /v1/activity?size=5 resolves 200 with 5 events, the first being Marta Alves publishing the article "Spring release" 2 minutes ago When the user navigates to /home Then the card title is "Recent activity" and "View all" links to /activity And row 1 reads "Published the article Spring release" with "Spring release" in semibold, over "Marta Alves · 2 min ago", with a 24 px avatar "MA" And exactly 5 rows are shown and GET /v1/activity was requested once with size=5 and no other parameter Examples: | role | | owner | | admin | Scenario Outline: Home, regular user, own events only and no actor name Given the session role is "" and GET /v1/activity?size=5 resolves 200 with 3 own events When the user navigates to /home Then the card title is "Your recent activity" And each row's meta line is only the time, for example "2 min ago" And no actor parameter was sent Examples: | role | | editor | | absent | Scenario Outline: Home rows, failure and security markers Given an event with outcome "" and security Then its row shows Examples: | outcome | security | marker | | success | false | no badge and no shield | | failed | false | a "Failed" badge | | success | true | a shield icon and no badge | | failed | true | a "Failed" badge and no shield | Scenario Outline: Home rows, the time label follows the day Given the current time is 2026-10-06 15:00 local and an event created Then the meta time reads "