Compare commits
10 Commits
feat/issue
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec0e4dfa45 | ||
|
|
d134990343 | ||
|
|
21b1b3b835 | ||
|
|
33aff36867 | ||
|
|
e18282318a | ||
|
|
c6fe61f06b | ||
|
|
182d014971 | ||
|
|
dc9d1d52b3 | ||
| 8558567688 | |||
|
|
6dae4fe428 |
@@ -192,17 +192,52 @@ jobs:
|
|||||||
REPO="${{ github.repository }}"
|
REPO="${{ github.repository }}"
|
||||||
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
||||||
|
|
||||||
|
# --- Gitea API helper ---
|
||||||
|
# api METHOD URL [extra curl args...] — authenticated Gitea API call.
|
||||||
|
# `curl -sf` collapses every HTTP >=400 into a bare "exit 22", which
|
||||||
|
# surfaces as an opaque step failure (issue #839). Instead we read the
|
||||||
|
# status code and, on a >=400 response, print an actionable ::error::
|
||||||
|
# to stderr (so a calling command substitution does not swallow it) and
|
||||||
|
# return 1 — `set -e` then still fails the step. The token is never
|
||||||
|
# echoed (no set -x; never placed in the message).
|
||||||
|
api() {
|
||||||
|
local method="$1" url="$2"; shift 2
|
||||||
|
local resp http
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -X "$method" \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" "$@" -- "$url")
|
||||||
|
http=${resp##*$'\n'}
|
||||||
|
printf '%s' "${resp%$'\n'*}"
|
||||||
|
case "$http" in
|
||||||
|
2*|3*) return 0 ;;
|
||||||
|
401|403)
|
||||||
|
echo "::error::Gitea returned HTTP $http for $method ${url%%\?*} — the NIGHTLY_AUDIT_TOKEN secret is missing, expired, or lacks issue read+write scope; recreate the renovate_bot PAT and update the secret." >&2
|
||||||
|
return 1 ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Gitea returned HTTP ${http:-(none)} for $method ${url%%\?*}." >&2
|
||||||
|
return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
||||||
# Tests the exact jq test() call used in the dedupe step, before any
|
# Runs before any real API call so broken logic fails loudly early:
|
||||||
# API call, so a broken matcher fails loudly early rather than silently
|
# (a) the jq title matcher used by the dedupe step — proves the regex
|
||||||
# opening duplicate issues. Proves the regex only — create-vs-update
|
# only; the create-vs-update decision is exercised by the
|
||||||
# decision is exercised by the workflow_dispatch AC.
|
# workflow_dispatch AC;
|
||||||
|
# (b) the api helper's HTTP-status handling, driven by a mocked curl so
|
||||||
|
# it needs no network — proves a 2xx returns the body and a >=400
|
||||||
|
# fails with an ::error:: instead of an opaque exit 22.
|
||||||
echo "{\"title\": \"${MARKER}\"}" \
|
echo "{\"title\": \"${MARKER}\"}" \
|
||||||
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
||||||
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
||||||
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
||||||
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
||||||
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
||||||
|
( curl() { printf 'OK\n200'; }; [ "$(api GET selftest)" = "OK" ] ) \
|
||||||
|
|| { echo "FAIL: self-test — api helper dropped body on HTTP 200"; exit 1; }
|
||||||
|
( curl() { printf 'nope\n401'; }
|
||||||
|
if api GET selftest >/dev/null 2>/tmp/api_selftest_err; then exit 1; fi
|
||||||
|
grep -q '::error::' /tmp/api_selftest_err ) \
|
||||||
|
|| { echo "FAIL: self-test — api helper did not emit ::error:: on HTTP 401"; exit 1; }
|
||||||
echo "Self-test passed."
|
echo "Self-test passed."
|
||||||
|
|
||||||
# --- Run audit ---
|
# --- Run audit ---
|
||||||
@@ -237,8 +272,7 @@ jobs:
|
|||||||
# Renovate vuln PRs also carry the "security" label, so >1 open
|
# Renovate vuln PRs also carry the "security" label, so >1 open
|
||||||
# "security" issue WILL occur. Title-match (not just label) ensures
|
# "security" issue WILL occur. Title-match (not just label) ensures
|
||||||
# we deduplicate only our own tracking issue.
|
# we deduplicate only our own tracking issue.
|
||||||
OPEN_ISSUES=$(curl -sf \
|
OPEN_ISSUES=$(api GET \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
||||||
|
|
||||||
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
||||||
@@ -255,11 +289,10 @@ jobs:
|
|||||||
--arg run_url "$RUN_URL" \
|
--arg run_url "$RUN_URL" \
|
||||||
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
||||||
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
||||||
curl -sf -X PATCH \
|
api PATCH \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD" > /dev/null
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
|
|
||||||
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
||||||
else
|
else
|
||||||
# Closed prior issue that recurs → new issue (not reopened).
|
# Closed prior issue that recurs → new issue (not reopened).
|
||||||
@@ -268,24 +301,21 @@ jobs:
|
|||||||
--arg title "$MARKER" \
|
--arg title "$MARKER" \
|
||||||
--arg body "$ISSUE_BODY" \
|
--arg body "$ISSUE_BODY" \
|
||||||
'{"title": $title, "body": $body}')
|
'{"title": $title, "body": $body}')
|
||||||
CREATED=$(curl -sf -X POST \
|
CREATED=$(api POST \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD")
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
|
|
||||||
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
||||||
echo "Opened new tracking issue #${NEW_NUMBER}"
|
echo "Opened new tracking issue #${NEW_NUMBER}"
|
||||||
|
|
||||||
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
||||||
LABEL_IDS=$(curl -sf \
|
LABEL_IDS=$(api GET \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
||||||
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
||||||
curl -sf -X POST \
|
api POST \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"labels\": $LABEL_IDS}" \
|
-d "{\"labels\": $LABEL_IDS}" > /dev/null
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$AUDIT_EXIT"
|
exit "$AUDIT_EXIT"
|
||||||
|
|||||||
@@ -173,3 +173,13 @@
|
|||||||
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
||||||
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
|
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
|
||||||
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
||||||
|
| REQ-001 | TimelineFilters is presentation-only (3 $bindable layer booleans + onChange); no goto/url.searchParams/api.GET/fetch | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#renders the three layer toggles with accessible names`, `#reflects a layer as pressed and flips it, firing onChange`; `timelineFilterBoundary.spec.ts` | Done |
|
||||||
|
| REQ-002 | route derives a client-side $derived filtered view, passes it to TimelineView; no goto/fetch on toggle | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#hides letter cards when the Letters layer is off ... with no fetch`; `timelineFilterBoundary.spec.ts` | Done |
|
||||||
|
| REQ-003 | Personal off → personal events (curated + derived life-events) hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides personal events — curated and derived`, `page.svelte.spec.ts#hides personal event cards` | Done |
|
||||||
|
| REQ-004 | Historical off → historical event entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides HISTORICAL events`, `page.svelte.spec.ts#hides historical event cards` | Done |
|
||||||
|
| REQ-005 | Letters off → letter entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides LETTER entries`, `page.svelte.spec.ts#hides letter cards` | Done |
|
||||||
|
| REQ-006 | zero visible → filter empty-state + one-click reset below the open bar (never blank, never generic empty) | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#shows the filtered-empty message + reset below the open bar`, `timelineFilter.spec.ts#drops year bands that become empty` | Done |
|
||||||
|
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
|
||||||
|
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
|
||||||
|
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
|
||||||
|
| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
|
||||||
|
|||||||
93
frontend/e2e/zeitstrahl-filter.spec.ts
Normal file
93
frontend/e2e/zeitstrahl-filter.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global /zeitstrahl layer filter (#780). Runs against the real stack with the
|
||||||
|
* seeded admin session (auth.setup). Covers the primary journey (hide the
|
||||||
|
* Letters layer → letter cards vanish + the trigger reports one active filter →
|
||||||
|
* reset restores everything) and a 375px axe pass with the collapsible open in
|
||||||
|
* both light and dark mode.
|
||||||
|
*
|
||||||
|
* #779 (the /zeitstrahl route) is merged, so this spec is NOT skipped. Per
|
||||||
|
* e2e/CLAUDE.md, E2E is not yet wired into CI — this axe gate runs locally only
|
||||||
|
* for now.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
|
||||||
|
const res = await request.post('/api/persons', {
|
||||||
|
data: { personType: 'PERSON', firstName, lastName }
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
|
||||||
|
return (await res.json()).id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds one dated letter so the timeline has content (and a LetterCard to hide). */
|
||||||
|
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
|
||||||
|
const senderId = await createPerson(request, 'Filter-Test', `Absender ${stamp()}`);
|
||||||
|
const receiverId = await createPerson(request, 'Filter-Test', `Empfaenger ${stamp()}`);
|
||||||
|
|
||||||
|
const createRes = await request.post('/api/documents', { multipart: { title } });
|
||||||
|
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
|
||||||
|
const docId = (await createRes.json()).id as string;
|
||||||
|
|
||||||
|
const put = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title,
|
||||||
|
documentDate: isoDate,
|
||||||
|
metaDatePrecision: 'DAY',
|
||||||
|
senderId,
|
||||||
|
receiverIds: receiverId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Zeitstrahl — layer filter (#780)', () => {
|
||||||
|
test('hiding the Letters layer removes letter cards and reports the active count; reset restores', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
// A sparse year keeps the seeded letter an individual card (not a dense strip).
|
||||||
|
const title = `E2E Filter Brief ${stamp()}`;
|
||||||
|
await seedDatedLetter(request, '1903-03-03', title);
|
||||||
|
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByText(title)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
|
||||||
|
await expect(page.getByText(title)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('timeline-filter-trigger')).toContainText('1 aktiv');
|
||||||
|
|
||||||
|
await page.getByTestId('timeline-filter-reset').click();
|
||||||
|
await expect(page.getByText(title)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no wcag2a/wcag2aa violations at 375px with the filter bar open (light + dark)', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
await seedDatedLetter(request, '1915-06-15', `E2E Filter A11y ${stamp()}`);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 375, height: 800 });
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the collapsible so axe scans the toggles, not just the trigger.
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await expect(page.getByTestId('timeline-filter-personal')).toBeVisible();
|
||||||
|
|
||||||
|
const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||||
|
|
||||||
|
const light = await scan();
|
||||||
|
expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]);
|
||||||
|
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
const dark = await scan();
|
||||||
|
expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1057,6 +1057,14 @@
|
|||||||
"timeline_events_count": "{count} Ereignisse",
|
"timeline_events_count": "{count} Ereignisse",
|
||||||
"timeline_letters_count_singular": "1 Brief",
|
"timeline_letters_count_singular": "1 Brief",
|
||||||
"timeline_events_count_singular": "1 Ereignis",
|
"timeline_events_count_singular": "1 Ereignis",
|
||||||
|
"timeline_filter_label_layers": "Ebenen anzeigen",
|
||||||
|
"timeline_filter_layer_personal": "Persönliche Ereignisse",
|
||||||
|
"timeline_filter_layer_historical": "Historische Ereignisse",
|
||||||
|
"timeline_filter_layer_letters": "Briefe",
|
||||||
|
"timeline_filter_trigger": "Filter",
|
||||||
|
"timeline_filter_trigger_active": "Filter ({count} aktiv)",
|
||||||
|
"timeline_filter_reset": "Filter zurücksetzen",
|
||||||
|
"timeline_filter_empty_state": "Keine Einträge entsprechen diesen Filtern.",
|
||||||
"event_editor_new_title": "Neues Ereignis",
|
"event_editor_new_title": "Neues Ereignis",
|
||||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
"event_editor_edit_title": "Ereignis bearbeiten",
|
||||||
"event_editor_section_when": "Wann",
|
"event_editor_section_when": "Wann",
|
||||||
|
|||||||
@@ -1057,6 +1057,14 @@
|
|||||||
"timeline_events_count": "{count} events",
|
"timeline_events_count": "{count} events",
|
||||||
"timeline_letters_count_singular": "1 letter",
|
"timeline_letters_count_singular": "1 letter",
|
||||||
"timeline_events_count_singular": "1 event",
|
"timeline_events_count_singular": "1 event",
|
||||||
|
"timeline_filter_label_layers": "Show layers",
|
||||||
|
"timeline_filter_layer_personal": "Personal events",
|
||||||
|
"timeline_filter_layer_historical": "Historical events",
|
||||||
|
"timeline_filter_layer_letters": "Letters",
|
||||||
|
"timeline_filter_trigger": "Filter",
|
||||||
|
"timeline_filter_trigger_active": "Filter ({count} active)",
|
||||||
|
"timeline_filter_reset": "Reset filters",
|
||||||
|
"timeline_filter_empty_state": "No entries match these filters.",
|
||||||
"event_editor_new_title": "New event",
|
"event_editor_new_title": "New event",
|
||||||
"event_editor_edit_title": "Edit event",
|
"event_editor_edit_title": "Edit event",
|
||||||
"event_editor_section_when": "When",
|
"event_editor_section_when": "When",
|
||||||
|
|||||||
@@ -1057,6 +1057,14 @@
|
|||||||
"timeline_events_count": "{count} eventos",
|
"timeline_events_count": "{count} eventos",
|
||||||
"timeline_letters_count_singular": "1 carta",
|
"timeline_letters_count_singular": "1 carta",
|
||||||
"timeline_events_count_singular": "1 evento",
|
"timeline_events_count_singular": "1 evento",
|
||||||
|
"timeline_filter_label_layers": "Mostrar capas",
|
||||||
|
"timeline_filter_layer_personal": "Eventos personales",
|
||||||
|
"timeline_filter_layer_historical": "Eventos históricos",
|
||||||
|
"timeline_filter_layer_letters": "Cartas",
|
||||||
|
"timeline_filter_trigger": "Filtro",
|
||||||
|
"timeline_filter_trigger_active": "Filtro ({count} activos)",
|
||||||
|
"timeline_filter_reset": "Restablecer filtros",
|
||||||
|
"timeline_filter_empty_state": "Ninguna entrada coincide con estos filtros.",
|
||||||
"event_editor_new_title": "Nuevo evento",
|
"event_editor_new_title": "Nuevo evento",
|
||||||
"event_editor_edit_title": "Editar evento",
|
"event_editor_edit_title": "Editar evento",
|
||||||
"event_editor_section_when": "Cuándo",
|
"event_editor_section_when": "Cuándo",
|
||||||
|
|||||||
@@ -98,4 +98,29 @@ describe('message key parity', () => {
|
|||||||
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
||||||
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #780 REQ-010: the layer-filter strings are Paraglide keys in every locale.
|
||||||
|
// timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count},
|
||||||
|
// ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)".
|
||||||
|
it('zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)', () => {
|
||||||
|
const requiredKeys = [
|
||||||
|
'timeline_filter_label_layers',
|
||||||
|
'timeline_filter_layer_personal',
|
||||||
|
'timeline_filter_layer_historical',
|
||||||
|
'timeline_filter_layer_letters',
|
||||||
|
'timeline_filter_trigger',
|
||||||
|
'timeline_filter_trigger_active',
|
||||||
|
'timeline_filter_reset',
|
||||||
|
'timeline_filter_empty_state'
|
||||||
|
];
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||||
|
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||||
|
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
// the active-count key carries the established {count} placeholder
|
||||||
|
expect(de.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
expect(en.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
expect(es.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
133
frontend/src/lib/timeline/TimelineFilters.svelte
Normal file
133
frontend/src/lib/timeline/TimelineFilters.svelte
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { hiddenLayerCount, isDefaultState, type TimelineLayerFilters } from './timelineFilter';
|
||||||
|
|
||||||
|
// Presentation-only layer filter for the global /zeitstrahl (#780, REQ-001).
|
||||||
|
// Holds no timeline data and never navigates or fetches — the route owns the
|
||||||
|
// $state and derives the filtered view. Three $bindable layer booleans plus an
|
||||||
|
// onChange notification hook are the whole contract.
|
||||||
|
let {
|
||||||
|
personalOn = $bindable(true),
|
||||||
|
historicalOn = $bindable(true),
|
||||||
|
lettersOn = $bindable(true),
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
personalOn?: boolean;
|
||||||
|
historicalOn?: boolean;
|
||||||
|
lettersOn?: boolean;
|
||||||
|
onChange?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// Reuse the reduced-motion guard expression from documents/[id]/+page.svelte:57
|
||||||
|
// for a new purpose — zeroing the slide duration so the collapsible opens
|
||||||
|
// instantly when the reader prefers reduced motion (REQ-009).
|
||||||
|
const prefersReducedMotion = $derived(
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
);
|
||||||
|
const slideDuration = $derived(prefersReducedMotion ? 0 : 200);
|
||||||
|
|
||||||
|
const filters: TimelineLayerFilters = $derived({ personalOn, historicalOn, lettersOn });
|
||||||
|
const hiddenCount = $derived(hiddenLayerCount(filters));
|
||||||
|
const anyLayerOff = $derived(!isDefaultState(filters));
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
personalOn = true;
|
||||||
|
historicalOn = true;
|
||||||
|
lettersOn = true;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet layerToggle(label: string, testid: string, pressed: boolean, toggle: () => void)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={testid}
|
||||||
|
aria-pressed={pressed}
|
||||||
|
onclick={toggle}
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 font-sans text-sm transition-colors {pressed
|
||||||
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="inline-flex h-4 w-4 items-center justify-center rounded-sm border {pressed
|
||||||
|
? 'border-primary-fg bg-primary-fg/20'
|
||||||
|
: 'border-ink-3'}"
|
||||||
|
>
|
||||||
|
{#if pressed}✓{/if}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<!-- Sticky trigger kept in document flow so the hidden-layer count stays
|
||||||
|
visible without clipping timeline content (REQ-007). -->
|
||||||
|
<div class="sticky top-16 z-20 bg-canvas py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-trigger"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? 'timeline-filter-panel' : undefined}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-2 rounded border border-line bg-surface px-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{hiddenCount === 0
|
||||||
|
? m.timeline_filter_trigger()
|
||||||
|
: m.timeline_filter_trigger_active({ count: hiddenCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div id="timeline-filter-panel" transition:slide={{ duration: slideDuration }}>
|
||||||
|
<fieldset class="mt-2 rounded-sm border border-line bg-surface p-4">
|
||||||
|
<legend class="px-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.timeline_filter_label_layers()}
|
||||||
|
</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{@render layerToggle(
|
||||||
|
m.timeline_filter_layer_personal(),
|
||||||
|
'timeline-filter-personal',
|
||||||
|
personalOn,
|
||||||
|
() => {
|
||||||
|
personalOn = !personalOn;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{@render layerToggle(
|
||||||
|
m.timeline_filter_layer_historical(),
|
||||||
|
'timeline-filter-historical',
|
||||||
|
historicalOn,
|
||||||
|
() => {
|
||||||
|
historicalOn = !historicalOn;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{@render layerToggle(
|
||||||
|
m.timeline_filter_layer_letters(),
|
||||||
|
'timeline-filter-letters',
|
||||||
|
lettersOn,
|
||||||
|
() => {
|
||||||
|
lettersOn = !lettersOn;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if anyLayerOff}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-reset"
|
||||||
|
onclick={reset}
|
||||||
|
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{m.timeline_filter_reset()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
74
frontend/src/lib/timeline/TimelineFilters.svelte.spec.ts
Normal file
74
frontend/src/lib/timeline/TimelineFilters.svelte.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import TimelineFilters from './TimelineFilters.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const allOn = () => ({ personalOn: true, historicalOn: true, lettersOn: true, onChange: vi.fn() });
|
||||||
|
|
||||||
|
async function openBar() {
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TimelineFilters', () => {
|
||||||
|
it('renders the three layer toggles with accessible names inside a labelled group (REQ-001)', async () => {
|
||||||
|
render(TimelineFilters, allOn());
|
||||||
|
await openBar();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.timeline_filter_layer_personal() }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.timeline_filter_layer_historical() }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.timeline_filter_layer_letters() }))
|
||||||
|
.toBeVisible();
|
||||||
|
// the fieldset legend groups the toggles
|
||||||
|
await expect.element(page.getByText(m.timeline_filter_label_layers())).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects a layer as pressed and flips it, firing onChange (REQ-001)', async () => {
|
||||||
|
const props = allOn();
|
||||||
|
render(TimelineFilters, props);
|
||||||
|
await openBar();
|
||||||
|
const personal = page.getByTestId('timeline-filter-personal');
|
||||||
|
await expect.element(personal).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await personal.click();
|
||||||
|
await expect.element(personal).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(props.onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a plain trigger when all layers are on and a count once a layer is hidden (REQ-007/010)', async () => {
|
||||||
|
render(TimelineFilters, allOn());
|
||||||
|
const trigger = page.getByTestId('timeline-filter-trigger');
|
||||||
|
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger());
|
||||||
|
await expect.element(trigger).not.toHaveTextContent('aktiv');
|
||||||
|
await trigger.click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger_active({ count: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the trigger a 44px touch target (REQ-007)', async () => {
|
||||||
|
render(TimelineFilters, allOn());
|
||||||
|
await expect.element(page.getByTestId('timeline-filter-trigger')).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the reset button by default and restores all layers when activated (REQ-008)', async () => {
|
||||||
|
const props = allOn();
|
||||||
|
render(TimelineFilters, props);
|
||||||
|
await openBar();
|
||||||
|
const reset = page.getByTestId('timeline-filter-reset');
|
||||||
|
// absent (not just hidden) while every layer is on
|
||||||
|
expect(reset.query()).toBeNull();
|
||||||
|
await page.getByTestId('timeline-filter-historical').click();
|
||||||
|
await expect.element(reset).toBeVisible();
|
||||||
|
await reset.click();
|
||||||
|
await expect
|
||||||
|
.element(page.getByTestId('timeline-filter-historical'))
|
||||||
|
.toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect.poll(() => reset.query()).toBeNull();
|
||||||
|
expect(props.onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
151
frontend/src/lib/timeline/timelineFilter.spec.ts
Normal file
151
frontend/src/lib/timeline/timelineFilter.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isDefaultState,
|
||||||
|
hiddenLayerCount,
|
||||||
|
filterTimeline,
|
||||||
|
ALL_LAYERS_ON,
|
||||||
|
type TimelineLayerFilters
|
||||||
|
} from './timelineFilter';
|
||||||
|
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||||
|
|
||||||
|
// Entry factories pinned to the three layers the filter discriminates (#780).
|
||||||
|
const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides });
|
||||||
|
|
||||||
|
const curatedPersonal = (overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Umzug nach Berlin',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived life-events carry type=PERSONAL (issue #776 REQ-009) — they belong to
|
||||||
|
// the Personal layer, not a fourth one.
|
||||||
|
const derivedLifeEvent = (overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: true,
|
||||||
|
derivedType: 'BIRTH',
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Geburt',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const historical = (overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Erster Weltkrieg',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const off = (overrides: Partial<TimelineLayerFilters>): TimelineLayerFilters => ({
|
||||||
|
...ALL_LAYERS_ON,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDefaultState (REQ-007)', () => {
|
||||||
|
it('is true when all three layers are on', () => {
|
||||||
|
expect(isDefaultState(ALL_LAYERS_ON)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when any single layer is off', () => {
|
||||||
|
expect(isDefaultState(off({ personalOn: false }))).toBe(false);
|
||||||
|
expect(isDefaultState(off({ historicalOn: false }))).toBe(false);
|
||||||
|
expect(isDefaultState(off({ lettersOn: false }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hiddenLayerCount (REQ-007)', () => {
|
||||||
|
it('is 0 in the default all-on state', () => {
|
||||||
|
expect(hiddenLayerCount(ALL_LAYERS_ON)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts each layer that is off', () => {
|
||||||
|
expect(hiddenLayerCount(off({ lettersOn: false }))).toBe(1);
|
||||||
|
expect(hiddenLayerCount(off({ personalOn: false, historicalOn: false }))).toBe(2);
|
||||||
|
expect(hiddenLayerCount({ personalOn: false, historicalOn: false, lettersOn: false })).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterTimeline', () => {
|
||||||
|
it('returns every entry unchanged in the default all-on state', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal()])],
|
||||||
|
undated: [letter({ documentId: 'u1' })]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, ALL_LAYERS_ON);
|
||||||
|
expect(result.years[0].entries).toHaveLength(3);
|
||||||
|
expect(result.undated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides LETTER entries when lettersOn is false, keeping events (REQ-005)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal()])],
|
||||||
|
undated: [letter({ documentId: 'u1' })]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ lettersOn: false }));
|
||||||
|
expect(result.years[0].entries.every((e) => e.kind !== 'LETTER')).toBe(true);
|
||||||
|
expect(result.years[0].entries).toHaveLength(2);
|
||||||
|
expect(result.undated).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides HISTORICAL events when historicalOn is false, keeping personal + letters (REQ-004)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ historicalOn: false }));
|
||||||
|
const kept = result.years[0].entries;
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(false);
|
||||||
|
expect(kept.some((e) => e.kind === 'LETTER')).toBe(true);
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(true);
|
||||||
|
expect(kept).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides personal events — curated and derived — when personalOn is false (REQ-003)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ personalOn: false }));
|
||||||
|
const kept = result.years[0].entries;
|
||||||
|
// neither the curated PERSONAL event nor the derived life-event survives
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(false);
|
||||||
|
expect(kept.some((e) => e.derived)).toBe(false);
|
||||||
|
// historical events and letters are untouched
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(true);
|
||||||
|
expect(kept.some((e) => e.kind === 'LETTER')).toBe(true);
|
||||||
|
expect(kept).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops year bands that become empty and filters the undated bucket (REQ-006)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [letter()]), // becomes empty when letters are hidden
|
||||||
|
makeYear(1918, [historical()]) // survives
|
||||||
|
],
|
||||||
|
undated: [letter({ documentId: 'u1' }), historical({ documentId: undefined })]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ lettersOn: false }));
|
||||||
|
expect(result.years).toHaveLength(1);
|
||||||
|
expect(result.years[0].year).toBe(1918);
|
||||||
|
expect(result.undated.every((e) => e.kind !== 'LETTER')).toBe(true);
|
||||||
|
expect(result.undated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the input timeline', () => {
|
||||||
|
const dto = makeTimelineDTO({ years: [makeYear(1915, [letter(), historical()])] });
|
||||||
|
filterTimeline(dto, off({ lettersOn: false }));
|
||||||
|
expect(dto.years[0].entries).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/lib/timeline/timelineFilter.ts
Normal file
63
frontend/src/lib/timeline/timelineFilter.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||||
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three visibility layers a reader can toggle on the global `/zeitstrahl`
|
||||||
|
* (#780). Purely a presentation concern — the whole timeline is loaded once by
|
||||||
|
* #779; these toggles derive a client-side filtered view of it.
|
||||||
|
*/
|
||||||
|
export interface TimelineLayerFilters {
|
||||||
|
/** Personal events — curated `PERSONAL` events and derived life-events. */
|
||||||
|
personalOn: boolean;
|
||||||
|
/** Historical events (`type === 'HISTORICAL'`). */
|
||||||
|
historicalOn: boolean;
|
||||||
|
/** Letters (`kind === 'LETTER'`). */
|
||||||
|
lettersOn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default view: every layer visible. */
|
||||||
|
export const ALL_LAYERS_ON: TimelineLayerFilters = {
|
||||||
|
personalOn: true,
|
||||||
|
historicalOn: true,
|
||||||
|
lettersOn: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when no layer is hidden — the default, all-on state (REQ-007). */
|
||||||
|
export function isDefaultState(filters: TimelineLayerFilters): boolean {
|
||||||
|
return filters.personalOn && filters.historicalOn && filters.lettersOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many layers are currently hidden — the "N active" trigger count (REQ-007). */
|
||||||
|
export function hiddenLayerCount(filters: TimelineLayerFilters): number {
|
||||||
|
return (
|
||||||
|
(filters.personalOn ? 0 : 1) + (filters.historicalOn ? 0 : 1) + (filters.lettersOn ? 0 : 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether one entry survives the active layer toggles. A letter rides
|
||||||
|
* the Letters layer; a historical event the Historical layer; everything else
|
||||||
|
* (curated `PERSONAL` events and derived life-events, which also carry
|
||||||
|
* `type === 'PERSONAL'`) the Personal layer.
|
||||||
|
*/
|
||||||
|
function isVisible(entry: TimelineEntryDTO, filters: TimelineLayerFilters): boolean {
|
||||||
|
if (entry.kind === 'LETTER') return filters.lettersOn;
|
||||||
|
if (entry.type === 'HISTORICAL') return filters.historicalOn;
|
||||||
|
return filters.personalOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a client-side filtered copy of the timeline (REQ-003/004/005/006).
|
||||||
|
* Year bands left empty by the active toggles are dropped so `TimelineView`
|
||||||
|
* never renders a hollow band, and the undated bucket is filtered the same way.
|
||||||
|
* Pure — the input DTO is never mutated.
|
||||||
|
*/
|
||||||
|
export function filterTimeline(timeline: TimelineDTO, filters: TimelineLayerFilters): TimelineDTO {
|
||||||
|
const years = timeline.years
|
||||||
|
.map((band) => ({ ...band, entries: band.entries.filter((e) => isVisible(e, filters)) }))
|
||||||
|
.filter((band) => band.entries.length > 0);
|
||||||
|
const undated = timeline.undated.filter((e) => isVisible(e, filters));
|
||||||
|
return { years, undated };
|
||||||
|
}
|
||||||
37
frontend/src/lib/timeline/timelineFilterBoundary.spec.ts
Normal file
37
frontend/src/lib/timeline/timelineFilterBoundary.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// REQ-001/002: the layer filter is presentation-only and fully client-side. It
|
||||||
|
// must never navigate or fetch — the route derives the filtered view from
|
||||||
|
// already-loaded data. This static guard mirrors the project's existing
|
||||||
|
// grep-gates (e.g. the no-`{@html}` checks) and fails the build if a future
|
||||||
|
// edit reintroduces navigation or a network call into either file.
|
||||||
|
const read = (relative: string) =>
|
||||||
|
readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8');
|
||||||
|
|
||||||
|
const FILES = {
|
||||||
|
'TimelineFilters.svelte': read('./TimelineFilters.svelte'),
|
||||||
|
'/zeitstrahl/+page.svelte': read('../../routes/zeitstrahl/+page.svelte')
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORBIDDEN: { label: string; pattern: RegExp }[] = [
|
||||||
|
{ label: 'goto(', pattern: /\bgoto\s*\(/ },
|
||||||
|
{ label: 'url.searchParams', pattern: /url\.searchParams/ },
|
||||||
|
{ label: 'api.GET', pattern: /\bapi\.GET\b/ },
|
||||||
|
{ label: 'fetch(', pattern: /\bfetch\s*\(/ }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('layer-filter boundary (REQ-001/002)', () => {
|
||||||
|
for (const [name, source] of Object.entries(FILES)) {
|
||||||
|
it(`${name} was found and is non-empty`, () => {
|
||||||
|
expect(source.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { label, pattern } of FORBIDDEN) {
|
||||||
|
it(`${name} contains no ${label}`, () => {
|
||||||
|
expect(pattern.test(source), `${name} must not use ${label}`).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,14 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
||||||
|
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
|
||||||
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
||||||
|
import { filterTimeline } from '$lib/timeline/timelineFilter';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const meta = $derived(timelineMeta(data.timeline));
|
|
||||||
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
||||||
|
|
||||||
|
// Layer-filter state (#780). Layer hiding is client-side only — the whole
|
||||||
|
// timeline is loaded once by #779's SSR load and we derive a filtered view of
|
||||||
|
// it here; there is no goto, no URL param, and no extra fetch.
|
||||||
|
let personalOn = $state(true);
|
||||||
|
let historicalOn = $state(true);
|
||||||
|
let lettersOn = $state(true);
|
||||||
|
|
||||||
|
const filteredTimeline = $derived(
|
||||||
|
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
|
||||||
|
);
|
||||||
|
const filteredEmpty = $derived(
|
||||||
|
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Meta-line figures track the *filtered* view, so the header counts always
|
||||||
|
// match what is actually on screen once layers are toggled off (#780 — this
|
||||||
|
// closes the prior D1 limitation, where the counts stayed on the full timeline).
|
||||||
|
const meta = $derived(timelineMeta(filteredTimeline));
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
personalOn = true;
|
||||||
|
historicalOn = true;
|
||||||
|
lettersOn = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Compose the sub-line from segments joined by " · " so the range drops out
|
// Compose the sub-line from segments joined by " · " so the range drops out
|
||||||
// cleanly when there are no year bands; the whole line is absent when the
|
// cleanly when there are no year bands; the whole line is absent when the
|
||||||
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
||||||
@@ -51,7 +77,29 @@ const metaLine = $derived.by(() => {
|
|||||||
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||||
{#if hasContent}
|
{#if hasContent}
|
||||||
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||||
|
<TimelineFilters
|
||||||
|
bind:personalOn={personalOn}
|
||||||
|
bind:historicalOn={historicalOn}
|
||||||
|
bind:lettersOn={lettersOn}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hasContent && filteredEmpty}
|
||||||
|
<!-- Filtered-empty: a calm message + one-click reset below the still-open
|
||||||
|
filter bar — never a blank page, and never the generic "no events"
|
||||||
|
state (which would imply the archive itself is empty). REQ-006. -->
|
||||||
|
<div data-testid="timeline-filter-empty" class="py-12 text-center">
|
||||||
|
<p class="font-serif text-base text-ink-2">{m.timeline_filter_empty_state()}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-empty-reset"
|
||||||
|
onclick={resetFilters}
|
||||||
|
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{m.timeline_filter_reset()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TimelineView timeline={filteredTimeline} />
|
||||||
{/if}
|
{/if}
|
||||||
<TimelineView timeline={data.timeline} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
|
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
|
||||||
@@ -111,3 +112,104 @@ describe('/zeitstrahl page', () => {
|
|||||||
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
|
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/zeitstrahl layer filter (#780)', () => {
|
||||||
|
const letter = (title: string, documentId: string) => makeEntry({ documentId, title });
|
||||||
|
const historical = (title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'h1',
|
||||||
|
documentId: undefined,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: ''
|
||||||
|
});
|
||||||
|
const personal = (title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'p1',
|
||||||
|
documentId: undefined,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixed = () =>
|
||||||
|
makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [
|
||||||
|
letter('Brief Eins', 'd1'),
|
||||||
|
historical('Erster Weltkrieg'),
|
||||||
|
personal('Umzug nach Berlin')
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openBar() {
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hides letter cards when the Letters layer is off and restores them, with no fetch (REQ-005/002)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.poll(() => page.getByText('Brief Eins').query()).toBeNull();
|
||||||
|
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides historical event cards when the Historical layer is off (REQ-004)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-historical').click();
|
||||||
|
await expect.poll(() => page.getByText('Erster Weltkrieg').query()).toBeNull();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides personal event cards when the Personal layer is off (REQ-003)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-personal').click();
|
||||||
|
await expect.poll(() => page.getByText('Umzug nach Berlin').query()).toBeNull();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Erster Weltkrieg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the filtered-empty message + reset below the open bar when all layers are off (REQ-006)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-personal').click();
|
||||||
|
await page.getByTestId('timeline-filter-historical').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(page.getByText(m.timeline_filter_empty_state())).toBeVisible();
|
||||||
|
await expect.element(page.getByTestId('timeline-filter-empty-reset')).toBeVisible();
|
||||||
|
// the generic TimelineView empty state is never what shows for a filtered-empty view
|
||||||
|
expect(page.getByText(m.timeline_empty_state()).query()).toBeNull();
|
||||||
|
// the one-click reset restores every layer
|
||||||
|
await page.getByTestId('timeline-filter-empty-reset').click();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes the meta-line counts from the filtered view, so a hidden layer drops out of the totals (#780, resolves D1)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
const meta = page.getByTestId('timeline-meta');
|
||||||
|
// all layers on → the one letter and the two events are counted
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_letters_count_singular());
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 }));
|
||||||
|
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
|
||||||
|
// the hidden letter leaves the count instead of lingering as "1 Brief";
|
||||||
|
// the event total is untouched
|
||||||
|
await expect.element(meta).not.toHaveTextContent(m.timeline_letters_count_singular());
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user