feat: unify /notifications and dashboard activity feed into a /chronik page #288

Merged
marcel merged 19 commits from feat/issue-285-chronik-unified-activity into main 2026-04-20 20:38:12 +02:00
46 changed files with 2271 additions and 740 deletions

View File

@@ -12,4 +12,6 @@ public interface ActivityFeedRow {
UUID getDocumentId();
Instant getHappenedAt();
boolean isYouMentioned();
int getCount();
Instant getHappenedAtUntil();
}

View File

@@ -23,34 +23,80 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
@Query(value = """
SELECT * FROM (
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
a.kind AS kind,
a.actor_id AS actorId,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
a.document_id AS documentId,
a.happened_at AS happened_at,
(a.kind = 'MENTION_CREATED'
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
WITH events AS (
SELECT
a.kind,
a.actor_id,
a.document_id,
a.happened_at,
a.payload,
LAG(a.happened_at) OVER (
PARTITION BY a.actor_id, a.document_id, a.kind
ORDER BY a.happened_at
) AS prev_happened_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED',
'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED')
AND a.document_id IS NOT NULL
ORDER BY a.actor_id, a.document_id, a.kind,
date_trunc('hour', a.happened_at), a.happened_at DESC
) deduped
ORDER BY happened_at DESC
),
sessions_marked AS (
SELECT
kind, actor_id, document_id, happened_at, payload,
CASE
WHEN kind IN ('COMMENT_ADDED','MENTION_CREATED') THEN 1
WHEN prev_happened_at IS NULL THEN 1
WHEN EXTRACT(EPOCH FROM (happened_at - prev_happened_at)) > 7200 THEN 1
ELSE 0
END AS is_new_session
FROM events
),
sessions AS (
SELECT
kind, actor_id, document_id, happened_at, payload,
SUM(is_new_session) OVER (
PARTITION BY actor_id, document_id, kind
ORDER BY happened_at
ROWS UNBOUNDED PRECEDING
) AS session_id
FROM sessions_marked
),
aggregated AS (
SELECT
s.kind,
s.actor_id,
s.document_id,
s.session_id,
MIN(s.happened_at) AS happened_at,
CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until,
COUNT(*)::int AS count,
BOOL_OR(s.kind = 'MENTION_CREATED'
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned
FROM sessions s
GROUP BY s.kind, s.actor_id, s.document_id, s.session_id
)
SELECT
ag.kind AS kind,
ag.actor_id AS actorId,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
ag.document_id AS documentId,
ag.happened_at AS happened_at,
ag.you_mentioned AS youMentioned,
ag.count AS count,
ag.happened_at_until AS happenedAtUntil
FROM aggregated ag
LEFT JOIN users u ON u.id = ag.actor_id
ORDER BY ag.happened_at DESC
LIMIT :limit
""", nativeQuery = true)
List<ActivityFeedRow> findDedupedActivityFeed(
List<ActivityFeedRow> findRolledUpActivityFeed(
@Param("currentUserId") String currentUserId,
@Param("limit") int limit);

View File

@@ -17,7 +17,7 @@ public class AuditLogQueryService {
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
return queryRepository.findDedupedActivityFeed(currentUserId.toString(), limit);
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit);
}
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {

View File

@@ -14,5 +14,7 @@ public record ActivityFeedItemDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
@Nullable OffsetDateTime happenedAtUntil
) {}

View File

@@ -37,6 +37,6 @@ public class DashboardController {
Authentication authentication,
@RequestParam(defaultValue = "7") int limit) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
return dashboardService.getActivity(userId, Math.min(limit, 20));
return dashboardService.getActivity(userId, Math.min(limit, 40));
}
}

View File

@@ -130,13 +130,18 @@ public class DashboardService {
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
: null;
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
: null;
return new ActivityFeedItemDTO(
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
actor,
row.getDocumentId(),
docTitle,
row.getHappenedAt().atOffset(ZoneOffset.UTC),
row.isYouMentioned()
row.isYouMentioned(),
row.getCount(),
happenedAtUntil
);
}).toList();
}

View File

@@ -0,0 +1,7 @@
-- Partial covering index for the session-style activity feed rollup (#285).
-- Matches the WHERE clause of AuditLogQueryRepository.findRolledUpActivityFeed
-- exactly. DESC on happened_at supports the outer ORDER BY without a sort step.
CREATE INDEX idx_audit_log_rollup
ON audit_log (actor_id, document_id, kind, happened_at DESC)
WHERE kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED',
'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED');

View File

@@ -49,13 +49,15 @@ class AuditLogQueryRepositoryIntegrationTest {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
})
void findDedupedActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
void findRolledUpActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getHappenedAt()).isNotNull();
assertThat(rows.get(0).getCount()).isEqualTo(1);
assertThat(rows.get(0).getHappenedAtUntil()).isNull();
}
@Test

View File

@@ -0,0 +1,176 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
@Transactional
class AuditLogQueryRepositoryRolledUpTest {
static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Autowired JdbcTemplate jdbcTemplate;
private NamedParameterJdbcTemplate named() {
return new NamedParameterJdbcTemplate(jdbcTemplate);
}
private void insertUserAndDocs() {
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
USER_ID, "rollup-" + USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')",
DOC_ID);
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief B', 'b.pdf', 'PLACEHOLDER')",
OTHER_DOC_ID);
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) {
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("kind", kind)
.addValue("actor", actorId)
.addValue("doc", docId)
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC));
named().update(
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) "
+ "VALUES (:kind, :actor, :doc, :t)",
params);
}
@Test
void rolledUpFeed_combines_same_actor_same_doc_within_2h() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T09:00:00Z");
for (int i = 0; i < 20; i++) {
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
}
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).hasSize(1);
ActivityFeedRow row = rows.get(0);
assertThat(row.getKind()).isEqualTo("TEXT_SAVED");
assertThat(row.getDocumentId()).isEqualTo(DOC_ID);
assertThat(row.getCount()).isEqualTo(20);
assertThat(row.getHappenedAt()).isEqualTo(base);
assertThat(row.getHappenedAtUntil()).isEqualTo(base.plusSeconds(19 * 480L));
}
@Test
void rolledUpFeed_splits_at_2h_boundary() {
insertUserAndDocs();
Instant sessionOneStart = Instant.parse("2026-04-20T08:00:00Z");
Instant sessionOneLast = sessionOneStart.plusSeconds(600);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneStart);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneLast);
Instant sessionTwoStart = sessionOneLast.plusSeconds(2L * 60L * 60L + 60L);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).hasSize(2);
assertThat(rows.get(0).getCount()).isEqualTo(2);
assertThat(rows.get(0).getHappenedAt()).isEqualTo(sessionTwoStart);
assertThat(rows.get(1).getCount()).isEqualTo(2);
assertThat(rows.get(1).getHappenedAt()).isEqualTo(sessionOneStart);
}
@Test
void rolledUpFeed_has_no_hard_cap_on_long_session() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T06:00:00Z");
for (int i = 0; i < 30; i++) {
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L));
}
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCount()).isEqualTo(30);
assertThat(rows.get(0).getHappenedAt()).isEqualTo(base);
assertThat(rows.get(0).getHappenedAtUntil()).isEqualTo(base.plusSeconds(29 * 60L * 30L));
}
@Test
void rolledUpFeed_never_rolls_up_COMMENT_ADDED_or_MENTION_CREATED() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base);
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).hasSize(3);
assertThat(rows).allSatisfy(r -> {
assertThat(r.getKind()).isEqualTo("COMMENT_ADDED");
assertThat(r.getCount()).isEqualTo(1);
assertThat(r.getHappenedAtUntil()).isNull();
});
}
@Test
void rolledUpFeed_excludes_non_eligible_kinds() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T12:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "STATUS_CHANGED", base);
insertAuditEvent(USER_ID, DOC_ID, "METADATA_UPDATED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
}
@Test
void rolledUpFeed_exposes_count_and_happenedAtUntil_on_singletons_and_rollups() {
insertUserAndDocs();
Instant rollupStart = Instant.parse("2026-04-20T11:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(300));
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(900));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).hasSize(2);
assertThat(rows).anySatisfy(r -> {
assertThat(r.getDocumentId()).isEqualTo(DOC_ID);
assertThat(r.getCount()).isEqualTo(2);
assertThat(r.getHappenedAt()).isEqualTo(rollupStart);
assertThat(r.getHappenedAtUntil()).isEqualTo(rollupStart.plusSeconds(300));
});
assertThat(rows).anySatisfy(r -> {
assertThat(r.getDocumentId()).isEqualTo(OTHER_DOC_ID);
assertThat(r.getCount()).isEqualTo(1);
assertThat(r.getHappenedAt()).isEqualTo(rollupStart.plusSeconds(900));
assertThat(r.getHappenedAtUntil()).isNull();
});
}
}

View File

@@ -140,4 +140,18 @@ class DashboardControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_clamps_limit_to_40() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity").param("limit", "9999"))
.andExpect(status().isOk());
org.mockito.Mockito.verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40));
}
}

View File

@@ -104,6 +104,8 @@ class DashboardServiceTest {
public UUID getDocumentId() { return docId; }
public Instant getHappenedAt() { return Instant.now(); }
public boolean isYouMentioned() { return false; }
public int getCount() { return 1; }
public Instant getHappenedAtUntil() { return null; }
};
}
}

View File

@@ -0,0 +1,59 @@
# ADR-003: Session-Rollup Unified Activity Feed on `/chronik`
## Status
Accepted
## Context
The app had two disconnected ways to see what was happening in the archive:
1. `/notifications` — personal mentions/replies only, delivered via the `notifications` table and a Bell dropdown.
2. Dashboard activity feed — ambient events (uploads, transcription, annotations, comments, mentions) via `/api/dashboard/activity`, which deduplicated using `DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at))`.
Two separate lists was a poor mental model (personal vs. ambient feel the same to the user), the `/notifications` page wasted horizontal space, the dashboard's "Alle anzeigen" pointed to `/documents` (dead-end), and the hour-trunc dedupe produced ugly splits on natural sessions — saving 20 transcription blocks at 08:58, 08:59, 09:01 yielded two rows.
We needed one page that merges both streams, keeps personal mentions visually loud, and aggregates ambient noise coherently.
## Decision
**One page `/chronik` backed by two endpoints.** The SvelteKit `+page.server.ts` composes data from `/api/dashboard/activity` (for the ambient timeline) and `/api/notifications` (for the "Für dich" box). No new `/api/chronik` orchestrator — the frontend load function is the composition seam.
**Session-style rollup replaces hour-trunc dedupe everywhere.** `AuditLogQueryRepository.findDedupedActivityFeed` is renamed to `findRolledUpActivityFeed` and rewritten using a `LAG()`-based session algorithm:
```
LAG(happened_at) OVER (PARTITION BY actor_id, document_id, kind ORDER BY happened_at)
→ is_new_session = gap > 7200s (or first event in partition, or kind ∈ {COMMENT_ADDED, MENTION_CREATED})
→ SUM(is_new_session) OVER (... ROWS UNBOUNDED PRECEDING) = session_id
→ GROUP BY (actor_id, document_id, kind, session_id) → MIN(happened_at), MAX(...), COUNT(*)
```
Events within 120 min on the same `(actor, document, kind)` become one row with `count` and `happenedAtUntil` fields. `COMMENT_ADDED` and `MENTION_CREATED` always start a new session — these kinds never roll up. No hard cap on total session span (a 4-hour transcription sitting is one row). The hour-trunc dedupe SQL is **deleted**, not kept alongside — one aggregation strategy per query.
**URL is universal German `/chronik` across all locales**, matching the existing convention (`/dokumente`, `/personen`, `/briefwechsel`). Content is translated via Paraglide; the URL is a stable German identifier, not a translatable route.
**DTO extended, not replaced.** `ActivityFeedItemDTO` gains `count: int` (required, `1` for singletons) and `happenedAtUntil: OffsetDateTime?` (null for singletons, end-of-session for rollups). One DTO shape serves both the Chronik timeline and the dashboard side-rail.
**`/notifications` route is deleted outright.** The app is pre-production — no 301 redirect, no zombie page.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Fixed 2-hour wall-clock buckets (`date_trunc('hour', happened_at / 2)`) | Splits natural sessions at bucket boundaries (e.g. events at 13:58 / 13:59 / 14:01 land in two rollup rows) |
| Keep `DISTINCT ON hour-trunc` alongside new rollup query | Two aggregation strategies = zombie logic; dashboard and Chronik would drift |
| New `/api/chronik` endpoint that merges both streams | Couples two domains (notifications + audit) at the API layer; composition belongs in `+page.server.ts` |
| Localized URL slugs (`/chronik` / `/chronicle` / `/crónica`) | Breaks the project's existing German-URL convention and adds Paraglide routing overhead for zero UX value |
| Per-locale rollup in the SQL (e.g. align to local-day boundaries) | Timezone-aware SQL is brittle; rollup is a time-gap concept, not a calendar-day concept |
## Consequences
**Easier:**
- One hot path — `/api/dashboard/activity` is backed by a single partial covering index (`V49__add_audit_log_rollup_index.sql`) that matches the rollup query's WHERE clause exactly.
- Dashboard side-rail gets rollup for free — 20 block-saves appear as one "Papa transkribierte 20 Blöcke" row with a time range, not 20 dedup'd hour buckets.
- Component reuse — `ChronikRow.svelte` renders both singleton and rollup variants via a `$derived` discriminator; `DashboardActivityFeed.svelte` consumes the same DTO shape.
**Harder:**
- The session SQL is ~15 lines longer than `DISTINCT ON`. That's the price for not splitting natural sessions at fixed boundaries — worth it on day one.
- Historical `/api/dashboard/activity` consumers now see `count` and `happenedAtUntil`. No breaking change — `count` defaults to `1`, `happenedAtUntil` is nullable — but pre-existing tests needed updating.
- Rollup is load-bearing for the UX — if the index is missing or the query regresses, the page either runs slow or returns duplicate rows. Covered by the rolledUp integration tests and the partial covering index; worth a follow-up Grafana panel on `/api/dashboard/activity` p95 latency.

View File

@@ -10,6 +10,7 @@ import { test, expect } from '@playwright/test';
const AUTHENTICATED_PAGES = [
{ name: 'home', path: '/' },
{ name: 'persons', path: '/persons' },
{ name: 'chronik', path: '/chronik' },
{ name: 'admin', path: '/admin' }
];

View File

@@ -751,5 +751,44 @@
"audit_action_comment_added": "hat kommentiert:",
"audit_action_mention_created": "hat dich erwähnt in",
"dropzone_release": "Loslassen zum Hochladen"
"dropzone_release": "Loslassen zum Hochladen",
"chronik_page_title": "Chronik",
"chronik_for_you_caption": "Für dich",
"chronik_for_you_count": "{count} neu",
"chronik_mark_read_aria": "Als gelesen markieren",
"chronik_mark_all_read": "Alle gelesen",
"chronik_inbox_zero_title": "Keine neuen Erwähnungen",
"chronik_inbox_zero_link": "Ältere Erwähnungen ansehen →",
"chronik_filter_label": "Aktivitäten filtern",
"chronik_filter_all": "Alle",
"chronik_filter_for_you": "Für dich",
"chronik_filter_uploaded": "Hochgeladen",
"chronik_filter_transcription": "Transkription",
"chronik_filter_comments": "Kommentare",
"chronik_day_today": "Heute",
"chronik_day_yesterday": "Gestern",
"chronik_day_this_week": "Diese Woche",
"chronik_day_older": "Älter",
"chronik_singleton_text_saved": "{actor} transkribierte einen Block in {doc}",
"chronik_rollup_text_saved": "{actor} transkribierte {count} Blöcke in {doc}",
"chronik_singleton_uploaded": "{actor} lud {doc} hoch",
"chronik_rollup_uploaded": "{actor} lud {count} Dokumente hoch",
"chronik_singleton_reviewed": "{actor} überprüfte einen Block in {doc}",
"chronik_rollup_reviewed": "{actor} überprüfte {count} Blöcke in {doc}",
"chronik_singleton_annotated": "{actor} annotierte {doc}",
"chronik_rollup_annotated": "{actor} annotierte {doc} {count}×",
"chronik_comment_added": "{actor} kommentierte {doc}",
"chronik_mention_created": "{actor} erwähnte dich in {doc}",
"chronik_reply_received": "{actor} antwortete dir in {doc}",
"chronik_empty_first_run_title": "Noch nichts geschehen",
"chronik_empty_first_run_body": "Sobald jemand aus der Familie Dokumente hochlädt oder transkribiert, erscheint hier die Aktivität.",
"chronik_empty_filter_title": "Nichts in dieser Ansicht",
"chronik_empty_filter_body": "In diesem Filter gibt es keine Einträge.",
"chronik_error_title": "Die Chronik konnte nicht geladen werden.",
"chronik_error_retry": "Erneut versuchen",
"chronik_load_more": "Mehr laden",
"chronik_loading": "Lädt …",
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
"chronik_view_all": "Zur Chronik →"
}

View File

@@ -751,5 +751,44 @@
"audit_action_comment_added": "commented:",
"audit_action_mention_created": "mentioned you in",
"dropzone_release": "Release to upload"
"dropzone_release": "Release to upload",
"chronik_page_title": "Chronicle",
"chronik_for_you_caption": "For you",
"chronik_for_you_count": "{count} new",
"chronik_mark_read_aria": "Mark as read",
"chronik_mark_all_read": "Mark all read",
"chronik_inbox_zero_title": "No new mentions",
"chronik_inbox_zero_link": "See older mentions →",
"chronik_filter_label": "Filter activity",
"chronik_filter_all": "All",
"chronik_filter_for_you": "For you",
"chronik_filter_uploaded": "Uploaded",
"chronik_filter_transcription": "Transcription",
"chronik_filter_comments": "Comments",
"chronik_day_today": "Today",
"chronik_day_yesterday": "Yesterday",
"chronik_day_this_week": "This week",
"chronik_day_older": "Older",
"chronik_singleton_text_saved": "{actor} transcribed a block in {doc}",
"chronik_rollup_text_saved": "{actor} transcribed {count} blocks in {doc}",
"chronik_singleton_uploaded": "{actor} uploaded {doc}",
"chronik_rollup_uploaded": "{actor} uploaded {count} documents",
"chronik_singleton_reviewed": "{actor} reviewed a block in {doc}",
"chronik_rollup_reviewed": "{actor} reviewed {count} blocks in {doc}",
"chronik_singleton_annotated": "{actor} annotated {doc}",
"chronik_rollup_annotated": "{actor} annotated {doc} {count}×",
"chronik_comment_added": "{actor} commented on {doc}",
"chronik_mention_created": "{actor} mentioned you in {doc}",
"chronik_reply_received": "{actor} replied to you in {doc}",
"chronik_empty_first_run_title": "Nothing has happened yet",
"chronik_empty_first_run_body": "As soon as someone in the family uploads or transcribes a document, the activity will show up here.",
"chronik_empty_filter_title": "Nothing in this view",
"chronik_empty_filter_body": "There are no entries for this filter.",
"chronik_error_title": "The chronicle could not be loaded.",
"chronik_error_retry": "Try again",
"chronik_load_more": "Load more",
"chronik_loading": "Loading …",
"chronik_load_more_announcement": "{count} more entries loaded",
"chronik_view_all": "Open chronicle →"
}

View File

@@ -751,5 +751,44 @@
"audit_action_comment_added": "comentó:",
"audit_action_mention_created": "te mencionó en",
"dropzone_release": "Suelta para subir"
"dropzone_release": "Suelta para subir",
"chronik_page_title": "Crónica",
"chronik_for_you_caption": "Para ti",
"chronik_for_you_count": "{count} nuevas",
"chronik_mark_read_aria": "Marcar como leído",
"chronik_mark_all_read": "Marcar todas leídas",
"chronik_inbox_zero_title": "Sin nuevas menciones",
"chronik_inbox_zero_link": "Ver menciones anteriores →",
"chronik_filter_label": "Filtrar actividad",
"chronik_filter_all": "Todas",
"chronik_filter_for_you": "Para ti",
"chronik_filter_uploaded": "Subidos",
"chronik_filter_transcription": "Transcripción",
"chronik_filter_comments": "Comentarios",
"chronik_day_today": "Hoy",
"chronik_day_yesterday": "Ayer",
"chronik_day_this_week": "Esta semana",
"chronik_day_older": "Anterior",
"chronik_singleton_text_saved": "{actor} transcribió un bloque en {doc}",
"chronik_rollup_text_saved": "{actor} transcribió {count} bloques en {doc}",
"chronik_singleton_uploaded": "{actor} subió {doc}",
"chronik_rollup_uploaded": "{actor} subió {count} documentos",
"chronik_singleton_reviewed": "{actor} revisó un bloque en {doc}",
"chronik_rollup_reviewed": "{actor} revisó {count} bloques en {doc}",
"chronik_singleton_annotated": "{actor} anotó {doc}",
"chronik_rollup_annotated": "{actor} anotó {doc} {count}×",
"chronik_comment_added": "{actor} comentó en {doc}",
"chronik_mention_created": "{actor} te mencionó en {doc}",
"chronik_reply_received": "{actor} te respondió en {doc}",
"chronik_empty_first_run_title": "Aún no ha pasado nada",
"chronik_empty_first_run_body": "En cuanto alguien de la familia suba o transcriba un documento, la actividad aparecerá aquí.",
"chronik_empty_filter_title": "Nada en esta vista",
"chronik_empty_filter_body": "No hay entradas para este filtro.",
"chronik_error_title": "No se pudo cargar la crónica.",
"chronik_error_retry": "Reintentar",
"chronik_load_more": "Cargar más",
"chronik_loading": "Cargando …",
"chronik_load_more_announcement": "{count} entradas más cargadas",
"chronik_view_all": "Abrir crónica →"
}

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { ActivityFeedItemDTO } from '$lib/generated/api';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
interface Props {
feed: ActivityFeedItemDTO[];
@@ -12,6 +14,7 @@ const verbMap: Record<string, string> = {
TEXT_SAVED: m.audit_action_text_saved(),
FILE_UPLOADED: m.audit_action_file_uploaded(),
ANNOTATION_CREATED: m.audit_action_annotation_created(),
BLOCK_REVIEWED: m.audit_action_annotation_created(),
COMMENT_ADDED: m.audit_action_comment_added(),
MENTION_CREATED: m.audit_action_mention_created()
};
@@ -27,6 +30,24 @@ function formatDate(iso: string): string {
year: 'numeric'
}).format(new Date(iso));
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(
new Date(iso)
);
}
// Rollup rows get "14. Apr. · 14:0214:32"; singletons stay "14. Apr. 2026".
function timestamp(item: ActivityFeedItemDTO): string {
if (item.happenedAtUntil && item.count > 1) {
const short = new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short'
}).format(new Date(item.happenedAt));
return `${short} \u00b7 ${formatTime(item.happenedAt)}\u2013${formatTime(item.happenedAtUntil)}`;
}
return formatDate(item.happenedAt);
}
</script>
<section class="rounded-sm border border-line bg-surface p-5">
@@ -35,7 +56,7 @@ function formatDate(iso: string): string {
{m.feed_caption()}
</h2>
<a
href="/documents"
href="/chronik"
aria-label={m.feed_show_all()}
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">{m.feed_show_all()}</a
>
@@ -66,6 +87,14 @@ function formatDate(iso: string): string {
<a href="/documents/{item.documentId}" class="underline hover:text-ink">
{item.documentTitle}
</a>
{#if item.count > 1}
<span
data-testid="feed-rollup-count"
class="ml-1.5 inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-[10px] font-bold text-primary-fg"
>
{item.count}
</span>
{/if}
{#if item.youMentioned}
<span
class="ml-1.5 inline-block rounded-full border border-accent px-2 py-px font-sans text-[10px] font-bold text-accent"
@@ -74,7 +103,7 @@ function formatDate(iso: string): string {
</span>
{/if}
</p>
<p class="mt-0.5 font-sans text-xs text-ink-3">{formatDate(item.happenedAt)}</p>
<p class="mt-0.5 font-sans text-xs text-ink-3">{timestamp(item)}</p>
</div>
</li>
{/each}

View File

@@ -17,7 +17,8 @@ const baseItem: ActivityFeedItemDTO = {
documentId: 'doc-1',
documentTitle: 'Brief 1920',
happenedAt: '2026-04-19T10:00:00Z',
youMentioned: false
youMentioned: false,
count: 1
};
describe('DashboardActivityFeed', () => {
@@ -39,4 +40,30 @@ describe('DashboardActivityFeed', () => {
const section = page.getByText('Kommentare & Aktivität');
await expect.element(section).toBeInTheDocument();
});
it('renders count badge and en-dash time range for rollup rows (count > 1)', async () => {
const rollup: ActivityFeedItemDTO = {
...baseItem,
count: 20,
happenedAtUntil: '2026-04-19T10:32:00Z'
};
render(DashboardActivityFeed, { feed: [rollup] });
const badge = page.getByTestId('feed-rollup-count');
await expect.element(badge).toHaveTextContent('20');
// "" is U+2013 en-dash
const stamp = page.getByText(/\u2013/);
await expect.element(stamp).toBeInTheDocument();
});
it('does not render count badge for singleton rows (count === 1)', async () => {
render(DashboardActivityFeed, { feed: [baseItem] });
const badge = page.getByTestId('feed-rollup-count');
await expect.element(badge).not.toBeInTheDocument();
});
it('links the "show all" footer to /chronik, not /documents', async () => {
render(DashboardActivityFeed, { feed: [] });
const link = page.getByRole('link', { name: /alle anzeigen/i });
await expect.element(link).toHaveAttribute('href', '/chronik');
});
});

View File

@@ -3,13 +3,13 @@ import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
import { notificationStore } from '$lib/stores/notifications.svelte';
import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false);
let bellButtonEl: HTMLButtonElement | null = null;
const stream = createNotificationStream();
const stream = notificationStore;
async function toggleDropdown() {
open = !open;

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/utils/time';
import type { NotificationItem } from '$lib/hooks/useNotificationStream.svelte';
import type { NotificationItem } from '$lib/stores/notifications.svelte';
type Props = {
notifications: NotificationItem[];
@@ -128,11 +128,11 @@ let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
<div class="border-t border-line px-4 py-2">
<a
href="/notifications"
href="/chronik"
onclick={onClose}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_view_all()}
{m.chronik_view_all()}
</a>
</div>
</div>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
export type EmptyVariant = 'first-run' | 'filter-empty' | 'inbox-zero';
interface Props {
variant: EmptyVariant;
}
const { variant }: Props = $props();
const title: string = $derived(
variant === 'first-run'
? m.chronik_empty_first_run_title()
: variant === 'filter-empty'
? m.chronik_empty_filter_title()
: m.chronik_inbox_zero_title()
);
const body: string = $derived(
variant === 'first-run'
? m.chronik_empty_first_run_body()
: variant === 'filter-empty'
? m.chronik_empty_filter_body()
: ''
);
</script>
<div
data-testid="chronik-empty-state"
data-variant={variant}
class="flex flex-col items-center gap-3 py-10 text-center"
>
{#if variant === 'first-run'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-ink-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{:else if variant === 'filter-empty'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-ink-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 4h18M6 8h12M9 12h6M10 16h4M11 20h2"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-accent"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15L15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
{/if}
<p class="font-sans text-base font-bold text-ink">
{title}
</p>
{#if body}
<p class="max-w-md font-sans text-sm text-ink-3">
{body}
</p>
{/if}
</div>

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ChronikEmptyState from './ChronikEmptyState.svelte';
afterEach(cleanup);
describe('ChronikEmptyState', () => {
it('renders first-run variant title', async () => {
render(ChronikEmptyState, { variant: 'first-run' });
await expect.element(page.getByText('Noch nichts geschehen')).toBeInTheDocument();
});
it('renders filter-empty variant title', async () => {
render(ChronikEmptyState, { variant: 'filter-empty' });
await expect.element(page.getByText('Nichts in dieser Ansicht')).toBeInTheDocument();
});
it('renders inbox-zero variant title', async () => {
render(ChronikEmptyState, { variant: 'inbox-zero' });
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
});
it('applies the expected data-variant attribute', async () => {
render(ChronikEmptyState, { variant: 'first-run' });
const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
expect(wrapper?.getAttribute('data-variant')).toBe('first-run');
});
});

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
onRetry: () => void;
message?: string;
}
const { onRetry, message }: Props = $props();
const displayMessage: string = $derived(message ?? m.chronik_error_title());
</script>
<div
role="alert"
class="flex items-start gap-3 rounded-sm border border-warning/40 bg-warning/10 p-4"
>
<span class="mt-0.5 text-warning-fg" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
</span>
<div class="min-w-0 flex-1">
<p class="font-sans text-sm text-warning-fg">
{displayMessage}
</p>
<button
type="button"
onclick={onRetry}
class="mt-2 inline-flex items-center rounded-sm bg-warning-fg px-3 py-1 font-sans text-xs font-medium text-surface transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
{m.chronik_error_retry()}
</button>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import ChronikErrorCard from './ChronikErrorCard.svelte';
afterEach(cleanup);
describe('ChronikErrorCard', () => {
it('renders the default error message', async () => {
render(ChronikErrorCard, { onRetry: vi.fn() });
await expect
.element(page.getByText('Die Chronik konnte nicht geladen werden.'))
.toBeInTheDocument();
});
it('renders the retry button with the expected label', async () => {
render(ChronikErrorCard, { onRetry: vi.fn() });
await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument();
});
it('renders a custom message when provided', async () => {
render(ChronikErrorCard, { onRetry: vi.fn(), message: 'Netzwerkfehler' });
await expect.element(page.getByText('Netzwerkfehler')).toBeInTheDocument();
});
it('calls onRetry when the retry button is clicked', async () => {
const onRetry = vi.fn();
render(ChronikErrorCard, { onRetry });
await userEvent.click(page.getByText('Erneut versuchen'));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('has role="alert" on the wrapper', async () => {
render(ChronikErrorCard, { onRetry: vi.fn() });
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
});

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare';
interface Props {
value: FilterValue;
onChange: (v: FilterValue) => void;
}
const { value, onChange }: Props = $props();
type Pill = { value: FilterValue; label: () => string };
const pills: Pill[] = [
{ value: 'alle', label: () => m.chronik_filter_all() },
{ value: 'fuer-dich', label: () => m.chronik_filter_for_you() },
{ value: 'hochgeladen', label: () => m.chronik_filter_uploaded() },
{ value: 'transkription', label: () => m.chronik_filter_transcription() },
{ value: 'kommentare', label: () => m.chronik_filter_comments() }
];
let container: HTMLDivElement | undefined = $state();
function moveFocus(direction: 1 | -1, fromValue: FilterValue): void {
if (!container) return;
const idx = pills.findIndex((p) => p.value === fromValue);
if (idx === -1) return;
const nextIdx = (idx + direction + pills.length) % pills.length;
const nextValue = pills[nextIdx].value;
const target = container.querySelector<HTMLButtonElement>(`[data-filter-value="${nextValue}"]`);
target?.focus();
}
function handleKeydown(e: KeyboardEvent, v: FilterValue): void {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
moveFocus(1, v);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
moveFocus(-1, v);
}
}
</script>
<div
bind:this={container}
role="radiogroup"
aria-label={m.chronik_filter_label()}
class="flex flex-wrap gap-2"
>
{#each pills as p (p.value)}
{@const active = p.value === value}
<button
type="button"
role="radio"
aria-checked={active}
tabindex={active ? 0 : -1}
data-filter-value={p.value}
onclick={() => onChange(p.value)}
onkeydown={(e) => handleKeydown(e, p.value)}
class="inline-flex min-h-[44px] items-center rounded-sm px-4 py-2 font-sans text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
{active ? 'bg-primary text-primary-fg' : 'bg-muted text-ink hover:bg-muted/80'}"
>
{p.label()}
</button>
{/each}
</div>

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import ChronikFilterPills from './ChronikFilterPills.svelte';
afterEach(cleanup);
describe('ChronikFilterPills', () => {
it('renders all 5 filter pills', async () => {
render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() });
const pills = document.querySelectorAll('[role="radio"]');
expect(pills.length).toBe(5);
});
it('marks the active pill with aria-checked="true"', async () => {
render(ChronikFilterPills, { value: 'hochgeladen', onChange: vi.fn() });
const pills = document.querySelectorAll('[role="radio"]');
const checked = Array.from(pills).filter((p) => p.getAttribute('aria-checked') === 'true');
expect(checked.length).toBe(1);
expect(checked[0].getAttribute('data-filter-value')).toBe('hochgeladen');
});
it('calls onChange with the clicked pill value', async () => {
const onChange = vi.fn();
render(ChronikFilterPills, { value: 'alle', onChange });
const pill = document.querySelector(
'[data-filter-value="kommentare"]'
) as HTMLButtonElement | null;
expect(pill).not.toBeNull();
pill?.click();
expect(onChange).toHaveBeenCalledWith('kommentare');
});
it('applies active classes to the selected pill', async () => {
render(ChronikFilterPills, { value: 'fuer-dich', onChange: vi.fn() });
const active = document.querySelector('[data-filter-value="fuer-dich"]');
expect(active?.className).toContain('bg-primary');
const inactive = document.querySelector('[data-filter-value="alle"]');
expect(inactive?.className).toContain('bg-muted');
});
it('ArrowRight moves focus to the next pill', async () => {
render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() });
const first = document.querySelector('[data-filter-value="alle"]') as HTMLButtonElement | null;
const second = document.querySelector(
'[data-filter-value="fuer-dich"]'
) as HTMLButtonElement | null;
expect(first).not.toBeNull();
expect(second).not.toBeNull();
first?.focus();
await userEvent.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(second);
});
it('ArrowLeft moves focus to the previous pill', async () => {
render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() });
const first = document.querySelector('[data-filter-value="alle"]') as HTMLButtonElement | null;
const second = document.querySelector(
'[data-filter-value="fuer-dich"]'
) as HTMLButtonElement | null;
second?.focus();
await userEvent.keyboard('{ArrowLeft}');
expect(document.activeElement).toBe(first);
});
it('wraps focus from last to first with ArrowRight', async () => {
render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() });
const last = document.querySelector(
'[data-filter-value="kommentare"]'
) as HTMLButtonElement | null;
const first = document.querySelector('[data-filter-value="alle"]') as HTMLButtonElement | null;
last?.focus();
await userEvent.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(first);
});
it('has role="radiogroup" on the container', async () => {
render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() });
const group = document.querySelector('[role="radiogroup"]');
expect(group).not.toBeNull();
// Paraglide provides "Aktivitäten filtern" as the filter label
expect(group?.getAttribute('aria-label')).toBe('Aktivitäten filtern');
});
});

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/utils/time';
import type { NotificationItem } from '$lib/stores/notifications.svelte';
interface Props {
unread: NotificationItem[];
onMarkRead: (n: NotificationItem) => void;
onMarkAllRead: () => void;
}
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
function verb(type: NotificationItem['type'], actor: string): string {
return type === 'REPLY'
? m.notification_type_reply({ actor })
: m.notification_type_mention({ actor });
}
function href(n: NotificationItem): string {
return `/documents/${n.documentId}?commentId=${n.referenceId}`;
}
</script>
<section class="rounded-sm border border-line bg-surface p-5">
{#if unread.length === 0}
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-accent"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15L15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
<p class="font-sans text-sm font-bold text-ink">
{m.chronik_inbox_zero_title()}
</p>
<a
href="/chronik?filter=fuer-dich"
class="font-sans text-xs text-ink-3 underline decoration-accent underline-offset-2 transition-colors hover:text-ink"
>
{m.chronik_inbox_zero_link()}
</a>
</div>
{:else}
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.chronik_for_you_caption()}
</span>
<span
data-testid="chronik-fuerdich-count"
aria-live="polite"
aria-atomic="true"
class="inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-xs text-primary-fg"
>
{m.chronik_for_you_count({ count: unread.length })}
</span>
</div>
<button
type="button"
data-testid="chronik-mark-all-read"
onclick={onMarkAllRead}
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.chronik_mark_all_read()}
</button>
</div>
<ul role="list" class="flex flex-col gap-2">
{#each unread as n (n.id)}
<li
class="fade-in group flex items-start gap-3 rounded-sm p-2 transition-colors hover:bg-canvas"
>
<a
href={href(n)}
class="flex min-w-0 flex-1 items-start gap-3 rounded-sm focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<span
aria-hidden="true"
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
>
{n.type === 'MENTION' ? '@' : '\u21A9'}
</span>
<div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink">
{verb(n.type, n.actorName)}
</p>
<p class="mt-0.5 font-sans text-xs text-ink-3">
{relativeTime(n.createdAt)}
</p>
</div>
</a>
<button
type="button"
data-testid="chronik-fuerdich-dismiss"
aria-label={m.chronik_mark_read_aria()}
onclick={() => onMarkRead(n)}
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.fade-in {
animation: chronik-fade-in 160ms ease-out;
}
@keyframes chronik-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/stores/notifications.svelte';
afterEach(cleanup);
function notif(partial: Partial<NotificationItem>): NotificationItem {
return {
id: 'n1',
type: 'MENTION',
documentId: 'doc-1',
documentTitle: 'Ein Dokument',
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
actorName: 'Anna',
...partial
};
}
describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, {
unread: [],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull();
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
});
it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, {
unread: [],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const link = document.querySelector('a[href="/chronik?filter=fuer-dich"]');
expect(link).not.toBeNull();
});
it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
});
it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
// Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
const badge = document.querySelector('[data-testid="chronik-fuerdich-count"]');
expect(badge?.getAttribute('aria-live')).toBe('polite');
expect(badge?.getAttribute('aria-atomic')).toBe('true');
});
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, {
unread: [],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
expect(all).toBeNull();
});
it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
});
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
onMarkRead: vi.fn(),
onMarkAllRead
});
await userEvent.click(page.getByText('Alle gelesen'));
expect(onMarkAllRead).toHaveBeenCalledTimes(1);
});
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, {
unread: [n],
onMarkRead,
onMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
});
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })],
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull();
// HTML spec forbids interactive content descendants of <a>.
// Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull();
});
});

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/utils/time';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type Variant = 'comment' | 'for-you' | 'rollup' | 'simple';
interface Props {
item: ActivityFeedItemDTO;
}
const { item }: Props = $props();
const variant: Variant = $derived(
item.kind === 'COMMENT_ADDED'
? 'comment'
: item.youMentioned
? 'for-you'
: item.count > 1
? 'rollup'
: 'simple'
);
function verbSingleton(kind: string, actor: string, doc: string): string {
switch (kind) {
case 'TEXT_SAVED':
return m.chronik_singleton_text_saved({ actor, doc });
case 'FILE_UPLOADED':
return m.chronik_singleton_uploaded({ actor, doc });
case 'BLOCK_REVIEWED':
return m.chronik_singleton_reviewed({ actor, doc });
case 'ANNOTATION_CREATED':
return m.chronik_singleton_annotated({ actor, doc });
case 'COMMENT_ADDED':
return m.chronik_comment_added({ actor, doc });
case 'MENTION_CREATED':
return m.chronik_mention_created({ actor, doc });
default:
return `${actor} · ${doc}`;
}
}
function verbRollup(kind: string, actor: string, doc: string, count: number): string {
switch (kind) {
case 'TEXT_SAVED':
return m.chronik_rollup_text_saved({ actor, doc, count });
case 'FILE_UPLOADED':
return m.chronik_rollup_uploaded({ actor, count });
case 'BLOCK_REVIEWED':
return m.chronik_rollup_reviewed({ actor, doc, count });
case 'ANNOTATION_CREATED':
return m.chronik_rollup_annotated({ actor, doc, count });
default:
return verbSingleton(kind, actor, doc);
}
}
function formatTimeHHMM(iso: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(d);
}
const actorName: string = $derived(item.actor?.name ?? item.actor?.initials ?? '?');
const docTitle: string = $derived(item.documentTitle);
// We split the translated verb around the document title so the title can be
// rendered as a styled <span> inside the <a> without nesting anchors. Using a
// non-printable sentinel (U+0001) as the {doc} interpolation value lets us
// split the compiled message regardless of what the actual title contains —
// empty strings, short substrings that also appear in the verb, and any
// translator sentence order all work without special cases.
const SENTINEL = '\u0001';
const verbText: string = $derived(
variant === 'rollup'
? verbRollup(item.kind, actorName, SENTINEL, item.count)
: verbSingleton(item.kind, actorName, SENTINEL)
);
const timeLabel: string = $derived(
variant === 'rollup' && item.happenedAtUntil
? `${formatTimeHHMM(item.happenedAt)}\u2013${formatTimeHHMM(item.happenedAtUntil)}`
: relativeTime(item.happenedAt)
);
const verbParts: { before: string; after: string } = $derived.by(() => {
const idx = verbText.indexOf(SENTINEL);
if (idx === -1) return { before: verbText, after: '' };
return {
before: verbText.slice(0, idx),
after: verbText.slice(idx + SENTINEL.length)
};
});
</script>
<a
href="/documents/{item.documentId}"
data-variant={variant}
class="group flex items-start gap-3 rounded-sm p-3 transition-colors hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
>
<!-- Avatar -->
{#if item.actor}
<span
class="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-sans text-sm font-bold text-white"
style="background:{item.actor.color}"
aria-hidden="true"
>
{item.actor.initials}
</span>
{:else}
<span
data-testid="chronik-avatar-fallback"
class="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-line font-sans text-sm text-ink-3"
aria-hidden="true"
>
?
</span>
{/if}
<!-- For-you marker (hidden on mobile) -->
{#if variant === 'for-you'}
<span
data-testid="chronik-foryou-marker"
aria-hidden="true"
class="mt-1 hidden h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent sm:inline-flex"
>
@
</span>
{/if}
<!-- Body -->
<div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink">
{verbParts.before}<span
data-testid="chronik-doc-title"
class="underline decoration-accent underline-offset-2">{docTitle}</span
>{verbParts.after}
{#if variant === 'rollup'}
<span
data-testid="chronik-count-badge"
class="ml-1 inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-xs text-primary-fg"
>
{item.count}
</span>
{/if}
</p>
{#if variant === 'comment'}
<!--
TODO: the backend does not yet expose a comment body preview on
ActivityFeedItemDTO. Render an ellipsis placeholder until it does —
duplicating the document title here looks like the comment is
quoting itself (Leonie, PR #288 review).
SECURITY: once item.commentPreview lands, render via {text}, never
{@html}. The backend must truncate and strip tags server-side (Nora,
issue #285 comment #3552).
-->
<p
data-testid="chronik-comment-preview"
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
>
&bdquo;&hellip;&ldquo;
</p>
{/if}
<p class="mt-0.5 font-sans text-xs text-ink-3">{timeLabel}</p>
</div>
</a>

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ChronikRow from './ChronikRow.svelte';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
afterEach(cleanup);
const baseItem: ActivityFeedItemDTO = {
kind: 'TEXT_SAVED',
actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' },
documentId: 'doc-1',
documentTitle: 'Brief 1920',
happenedAt: '2026-04-19T10:00:00Z',
youMentioned: false,
count: 1
};
describe('ChronikRow', () => {
it('renders the document title', async () => {
render(ChronikRow, { item: baseItem });
await expect.element(page.getByText('Brief 1920')).toBeInTheDocument();
});
it('renders actor initials in avatar', async () => {
render(ChronikRow, { item: baseItem });
await expect.element(page.getByText('MR')).toBeInTheDocument();
});
it('renders "?" fallback avatar when actor is missing', async () => {
const item: ActivityFeedItemDTO = { ...baseItem, actor: undefined };
render(ChronikRow, { item });
const fallback = document.querySelector('[data-testid="chronik-avatar-fallback"]');
expect(fallback).not.toBeNull();
expect(fallback?.textContent?.trim()).toBe('?');
});
it('wraps the row in a link to the document', async () => {
render(ChronikRow, { item: baseItem });
const link = document.querySelector('a[href="/documents/doc-1"]');
expect(link).not.toBeNull();
});
// --- simple variant ---
it('renders simple variant when count === 1 and not a mention', async () => {
render(ChronikRow, { item: baseItem });
// No rollup count badge
expect(document.querySelector('[data-testid="chronik-count-badge"]')).toBeNull();
// No for-you marker
expect(document.querySelector('[data-testid="chronik-foryou-marker"]')).toBeNull();
// No comment preview
expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull();
});
// --- rollup variant ---
it('renders rollup variant with count badge when count > 1', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'TEXT_SAVED',
count: 3,
happenedAt: '2026-04-19T10:00:00Z',
happenedAtUntil: '2026-04-19T11:30:00Z'
};
render(ChronikRow, { item });
const badge = document.querySelector('[data-testid="chronik-count-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent).toContain('3');
});
it('renders a time range with an en-dash for rollup variant', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'FILE_UPLOADED',
count: 5,
happenedAt: '2026-04-19T10:00:00Z',
happenedAtUntil: '2026-04-19T11:30:00Z'
};
render(ChronikRow, { item });
// en-dash character U+2013
const body = document.body.textContent ?? '';
expect(body).toContain('\u2013');
});
// --- for-you variant ---
it('renders for-you marker when youMentioned is true', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'MENTION_CREATED',
youMentioned: true
};
render(ChronikRow, { item });
const marker = document.querySelector('[data-testid="chronik-foryou-marker"]');
expect(marker).not.toBeNull();
});
it('applies accent border to for-you variant outer wrapper', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'MENTION_CREATED',
youMentioned: true
};
render(ChronikRow, { item });
const wrapper = document.querySelector('[data-variant="for-you"]');
expect(wrapper).not.toBeNull();
expect(wrapper?.className).toContain('border-accent');
});
// --- comment variant ---
it('renders comment preview for COMMENT_ADDED kind', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'COMMENT_ADDED'
};
render(ChronikRow, { item });
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
expect(preview).not.toBeNull();
});
it('comment preview does NOT duplicate the document title verbatim', async () => {
// Leonie: user sees the title twice otherwise — looks like the comment is quoting itself.
// Until the backend exposes item.commentPreview, the placeholder must be distinct.
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'COMMENT_ADDED',
documentTitle: 'Brief vom 12. Juli 1920'
};
render(ChronikRow, { item });
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
expect(preview).not.toBeNull();
expect(preview?.textContent).not.toContain('Brief vom 12. Juli 1920');
});
// --- robustness: title rendering for edge cases ---
it('still renders the row link when documentTitle is an empty string', async () => {
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
// collapsed and before/after both emptied out. Swap to a sentinel-based
// approach so this case renders like every other row.
const empty: ActivityFeedItemDTO = { ...baseItem, documentTitle: '' };
render(ChronikRow, { item: empty });
const link = document.querySelector('a[href="/documents/doc-1"]');
expect(link).not.toBeNull();
});
it('renders a short document title that could substring-match the verb', async () => {
const short: ActivityFeedItemDTO = { ...baseItem, documentTitle: 'Brief' };
render(ChronikRow, { item: short });
const titleEls = document.querySelectorAll('[data-testid="chronik-doc-title"]');
expect(titleEls.length).toBe(1);
expect(titleEls[0].textContent).toBe('Brief');
});
});

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { bucketByDay, type DayBucket } from '$lib/utils/date-buckets';
import type { components } from '$lib/generated/api';
import ChronikRow from './ChronikRow.svelte';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
interface Props {
items: ActivityFeedItemDTO[];
locale?: string;
}
const { items, locale }: Props = $props();
const BUCKET_ORDER: DayBucket[] = ['today', 'yesterday', 'thisWeek', 'older'];
function bucketLabel(bucket: DayBucket): string {
switch (bucket) {
case 'today':
return m.chronik_day_today();
case 'yesterday':
return m.chronik_day_yesterday();
case 'thisWeek':
return m.chronik_day_this_week();
case 'older':
return m.chronik_day_older();
}
}
const grouped: Record<DayBucket, ActivityFeedItemDTO[]> = $derived.by(() => {
const result: Record<DayBucket, ActivityFeedItemDTO[]> = {
today: [],
yesterday: [],
thisWeek: [],
older: []
};
for (const it of items) {
const b = bucketByDay(new Date(it.happenedAt), new Date(), locale);
result[b].push(it);
}
return result;
});
</script>
<div class="flex flex-col">
{#each BUCKET_ORDER as bucket (bucket)}
{#if grouped[bucket].length > 0}
<section data-testid="chronik-bucket-{bucket}">
<div class="mt-6 mb-2 flex items-center gap-3">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{bucketLabel(bucket)}
</span>
<span class="h-px flex-1 bg-line"></span>
</div>
<ul role="list" class="flex flex-col gap-2">
{#each grouped[bucket] as it (it.kind + it.happenedAt + it.documentId)}
<li>
<ChronikRow item={it} />
</li>
{/each}
</ul>
</section>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ChronikTimeline from './ChronikTimeline.svelte';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
afterEach(cleanup);
function item(partial: Partial<ActivityFeedItemDTO>): ActivityFeedItemDTO {
return {
kind: 'TEXT_SAVED',
actor: { initials: 'AB', color: '#123456', name: 'Anna Beta' },
documentId: 'doc-x',
documentTitle: 'Some document',
happenedAt: new Date().toISOString(),
youMentioned: false,
count: 1,
...partial
};
}
function atOffsetDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString();
}
describe('ChronikTimeline', () => {
it('renders nothing / no bucket headers when items is empty', async () => {
render(ChronikTimeline, { items: [] });
expect(document.querySelector('[data-testid="chronik-bucket-today"]')).toBeNull();
expect(document.querySelector('[data-testid="chronik-bucket-yesterday"]')).toBeNull();
expect(document.querySelector('[data-testid="chronik-bucket-thisWeek"]')).toBeNull();
expect(document.querySelector('[data-testid="chronik-bucket-older"]')).toBeNull();
});
it('places today items in the today bucket with a "Heute" header', async () => {
render(ChronikTimeline, {
items: [
item({
documentId: 'doc-today',
documentTitle: 'Frisches Dokument',
happenedAt: new Date().toISOString()
})
]
});
const today = document.querySelector('[data-testid="chronik-bucket-today"]');
expect(today).not.toBeNull();
await expect.element(page.getByText('Heute', { exact: true })).toBeInTheDocument();
// The row for the today item should be inside the today bucket.
expect(today?.textContent).toContain('Frisches Dokument');
});
it('does not render an empty bucket header when no items fall into it', async () => {
render(ChronikTimeline, {
items: [item({ happenedAt: new Date().toISOString() })]
});
// Only today bucket should exist.
expect(document.querySelector('[data-testid="chronik-bucket-today"]')).not.toBeNull();
expect(document.querySelector('[data-testid="chronik-bucket-older"]')).toBeNull();
});
it('places older items in the older bucket', async () => {
render(ChronikTimeline, {
items: [
item({
documentId: 'doc-old',
documentTitle: 'Alt Doc',
happenedAt: atOffsetDays(30)
})
]
});
const older = document.querySelector('[data-testid="chronik-bucket-older"]');
expect(older).not.toBeNull();
expect(older?.textContent).toContain('Alt Doc');
});
it('groups multiple items into their respective buckets', async () => {
render(ChronikTimeline, {
items: [
item({
documentId: 'd1',
documentTitle: 'Heute Item',
happenedAt: new Date().toISOString()
}),
item({ documentId: 'd2', documentTitle: 'Alt Item', happenedAt: atOffsetDays(30) })
]
});
const today = document.querySelector('[data-testid="chronik-bucket-today"]');
const older = document.querySelector('[data-testid="chronik-bucket-older"]');
expect(today?.textContent).toContain('Heute Item');
expect(today?.textContent).not.toContain('Alt Item');
expect(older?.textContent).toContain('Alt Item');
expect(older?.textContent).not.toContain('Heute Item');
});
});

View File

@@ -1799,6 +1799,7 @@ export interface components {
/** Format: uuid */
id?: string;
displayName?: string;
personType?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
@@ -1809,7 +1810,6 @@ export interface components {
deathYear?: number;
alias?: string;
notes?: string;
personType?: string;
};
SenderModel: {
/** Format: uuid */
@@ -1877,10 +1877,10 @@ export interface components {
timeout?: number;
};
PageNotificationDTO: {
/** Format: int32 */
totalPages?: number;
/** Format: int64 */
totalElements?: number;
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
@@ -2015,6 +2015,10 @@ export interface components {
/** Format: date-time */
happenedAt: string;
youMentioned: boolean;
/** Format: int32 */
count: number;
/** Format: date-time */
happenedAtUntil?: string;
};
InvitePrefillDTO: {
firstName: string;
@@ -4455,7 +4459,3 @@ export interface operations {
};
};
}
export type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
export type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
export type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];

View File

@@ -1,142 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '../useNotificationStream.svelte';
// Track the last created EventSource instance
let lastEventSource: {
close: ReturnType<typeof vi.fn>;
onopen: (() => void) | null;
onerror: (() => void) | null;
simulate: (type: string, data: string) => void;
} | null = null;
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
constructor() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastEventSource = this;
}
addEventListener(type: string, fn: (e: MessageEvent) => void) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(fn);
}
simulate(type: string, data: string) {
const event = new MessageEvent(type, { data });
for (const fn of this.listeners[type] ?? []) {
fn(event);
}
}
}
vi.stubGlobal('EventSource', MockEventSource);
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
// Import after stubs are set up
const { createNotificationStream } = await import('../useNotificationStream.svelte');
beforeEach(() => {
mockFetch.mockReset();
lastEventSource = null;
});
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
type: 'REPLY',
actorName: 'Hans',
documentId: 'doc-1',
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: new Date().toISOString(),
...overrides
};
}
describe('createNotificationStream', () => {
it('starts with empty notifications and zero unreadCount', () => {
const stream = createNotificationStream();
expect(stream.notifications).toHaveLength(0);
expect(stream.unreadCount).toBe(0);
});
it('fetchUnreadCount updates unreadCount from API', async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ count: 3 }), { status: 200 }));
const stream = createNotificationStream();
await stream.fetchUnreadCount();
expect(stream.unreadCount).toBe(3);
});
it('fetchNotifications populates notifications from API', async () => {
const items = [makeNotification()];
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ content: items }), { status: 200 })
);
const stream = createNotificationStream();
await stream.fetchNotifications();
expect(stream.notifications).toHaveLength(1);
expect(stream.notifications[0].id).toBe('n1');
});
it('markRead marks notification as read and decrements unreadCount', async () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }), { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const stream = createNotificationStream();
await stream.fetchUnreadCount();
const notification = makeNotification({ read: false });
await stream.markRead(notification);
expect(notification.read).toBe(true);
expect(stream.unreadCount).toBe(1);
});
it('markAllRead calls the API and resets unreadCount', async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
const stream = createNotificationStream();
await stream.markAllRead();
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(stream.unreadCount).toBe(0);
});
it('destroy closes the EventSource', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
expect(lastEventSource).not.toBeNull();
stream.destroy();
expect(lastEventSource!.close).toHaveBeenCalled();
});
it('SSE notification event prepends notification and increments unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(stream.notifications).toHaveLength(1);
expect(stream.notifications[0].id).toBe('sse-1');
expect(stream.unreadCount).toBe(1);
});
it('SSE notification event with read:true does not increment unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
const notification = makeNotification({ id: 'sse-2', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(stream.notifications).toHaveLength(1);
expect(stream.unreadCount).toBe(0);
});
});

View File

@@ -1,95 +0,0 @@
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
export type { NotificationItem };
export function createNotificationStream() {
let notifications = $state<NotificationItem[]>([]);
let unreadCount = $state(0);
let eventSource: EventSource | null = null;
async function fetchNotifications(): Promise<void> {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
}
async function fetchUnreadCount(): Promise<void> {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
async function markRead(notification: NotificationItem): Promise<void> {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
}
async function markAllRead(): Promise<void> {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
function init(): void {
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent(e.data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
};
eventSource.onerror = () => {
// Close on error to avoid repeated reconnect noise
eventSource?.close();
};
}
function destroy(): void {
eventSource?.close();
eventSource = null;
}
return {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
markRead,
markAllRead,
init,
destroy
};
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '$lib/utils/notifications';
let lastEventSource: MockEventSource | null = null;
let eventSourceCount = 0;
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
constructor() {
eventSourceCount += 1;
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastEventSource = this;
}
addEventListener(type: string, fn: (e: MessageEvent) => void) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(fn);
}
simulate(type: string, data: string) {
const event = new MessageEvent(type, { data });
for (const fn of this.listeners[type] ?? []) {
fn(event);
}
}
}
vi.stubGlobal('EventSource', MockEventSource);
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
const { notificationStore, __resetForTest } = await import('./notifications.svelte');
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
lastEventSource = null;
eventSourceCount = 0;
__resetForTest();
});
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
type: 'REPLY',
actorName: 'Hans',
documentId: 'doc-1',
documentTitle: null,
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: new Date().toISOString(),
...overrides
};
}
describe('notificationStore (singleton)', () => {
it('opens a single EventSource across multiple init() calls', () => {
notificationStore.init();
notificationStore.init();
notificationStore.init();
expect(eventSourceCount).toBe(1);
});
it('closes the EventSource only after every init() is matched with destroy()', () => {
notificationStore.init();
notificationStore.init();
const es = lastEventSource!;
notificationStore.destroy();
expect(es.close).not.toHaveBeenCalled();
notificationStore.destroy();
expect(es.close).toHaveBeenCalledTimes(1);
});
it('reopens a fresh EventSource after full teardown', () => {
notificationStore.init();
notificationStore.destroy();
notificationStore.init();
expect(eventSourceCount).toBe(2);
});
it('SSE notification event prepends notification and increments unreadCount', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(notificationStore.notifications[0].id).toBe('sse-1');
expect(notificationStore.unreadCount).toBe(1);
});
it('markAllRead resets unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
await notificationStore.markAllRead();
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.unreadCount).toBe(0);
});
});

View File

@@ -0,0 +1,108 @@
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
export type { NotificationItem };
let notifications = $state<NotificationItem[]>([]);
let unreadCount = $state(0);
let eventSource: EventSource | null = null;
let refCount = 0;
async function fetchNotifications(): Promise<void> {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
}
async function fetchUnreadCount(): Promise<void> {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
async function markRead(notification: NotificationItem): Promise<void> {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
}
async function markAllRead(): Promise<void> {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
function init(): void {
refCount += 1;
if (refCount > 1) return;
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent((e as MessageEvent).data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
};
eventSource.onerror = () => {
eventSource?.close();
};
}
function destroy(): void {
if (refCount === 0) return;
refCount -= 1;
if (refCount === 0) {
eventSource?.close();
eventSource = null;
}
}
export function __resetForTest(): void {
eventSource?.close();
eventSource = null;
refCount = 0;
notifications = [];
unreadCount = 0;
}
export const notificationStore = {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
markRead,
markAllRead,
init,
destroy
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { bucketByDay } from './date-buckets';
function date(iso: string): Date {
return new Date(iso);
}
describe('bucketByDay', () => {
// Wednesday 2026-04-22 at 12:00 Berlin. Week start (Mon) = 2026-04-20.
const now = date('2026-04-22T12:00:00+02:00');
it('returns "today" for a time earlier today', () => {
expect(bucketByDay(date('2026-04-22T06:00:00+02:00'), now, 'de-DE')).toBe('today');
});
it('returns "today" at exact midnight start of today', () => {
expect(bucketByDay(date('2026-04-22T00:00:00+02:00'), now, 'de-DE')).toBe('today');
});
it('returns "yesterday" for any time on the previous day', () => {
expect(bucketByDay(date('2026-04-21T23:59:59+02:00'), now, 'de-DE')).toBe('yesterday');
expect(bucketByDay(date('2026-04-21T00:00:00+02:00'), now, 'de-DE')).toBe('yesterday');
});
it('returns "thisWeek" for the Monday that starts this week (Monday-anchored, de-DE)', () => {
expect(bucketByDay(date('2026-04-20T10:00:00+02:00'), now, 'de-DE')).toBe('thisWeek');
});
it('returns "older" for anything before the start of this week (de-DE)', () => {
expect(bucketByDay(date('2026-04-19T23:00:00+02:00'), now, 'de-DE')).toBe('older');
expect(bucketByDay(date('2026-04-13T10:00:00+02:00'), now, 'de-DE')).toBe('older');
});
it('uses Sunday-start week for en-US', () => {
const sundayRef = date('2026-04-19T12:00:00+02:00');
expect(bucketByDay(date('2026-04-19T06:00:00+02:00'), sundayRef, 'en-US')).toBe('today');
expect(
bucketByDay(date('2026-04-13T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
).toBe('thisWeek');
expect(
bucketByDay(date('2026-04-11T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
).toBe('older');
});
it('handles DST spring-forward correctly (Europe/Berlin 2026-03-29)', () => {
const justAfterDst = date('2026-03-29T03:15:00+02:00');
const sameDay = date('2026-03-29T10:00:00+02:00');
expect(bucketByDay(justAfterDst, sameDay, 'de-DE')).toBe('today');
});
});

View File

@@ -0,0 +1,35 @@
export type DayBucket = 'today' | 'yesterday' | 'thisWeek' | 'older';
const DAY_MS = 24 * 60 * 60 * 1000;
const SUNDAY_START_LOCALES = new Set(['en-us', 'en-ca', 'en-ph', 'ja-jp', 'he-il', 'pt-br']);
function weekStartDay(locale?: string): 0 | 1 {
if (!locale) return 1;
return SUNDAY_START_LOCALES.has(locale.toLowerCase()) ? 0 : 1;
}
function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function startOfWeek(d: Date, firstDay: 0 | 1): Date {
const x = startOfDay(d);
const diff = (x.getDay() - firstDay + 7) % 7;
x.setDate(x.getDate() - diff);
return x;
}
export function bucketByDay(date: Date, now: Date = new Date(), locale?: string): DayBucket {
const today = startOfDay(now);
const target = startOfDay(date);
if (target.getTime() === today.getTime()) return 'today';
if (today.getTime() - target.getTime() <= DAY_MS) return 'yesterday';
const weekStart = startOfWeek(today, weekStartDay(locale));
if (target.getTime() >= weekStart.getTime()) return 'thisWeek';
return 'older';
}

View File

@@ -0,0 +1,54 @@
import { createApiClient } from '$lib/api.server';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type NotificationDTO = components['schemas']['NotificationDTO'];
export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare';
const VALID_FILTERS: FilterValue[] = [
'alle',
'fuer-dich',
'hochgeladen',
'transkription',
'kommentare'
];
function parseFilter(raw: string | null): FilterValue {
if (raw && (VALID_FILTERS as string[]).includes(raw)) return raw as FilterValue;
return 'alle';
}
export async function load({ fetch, url }) {
const api = createApiClient(fetch);
const filter = parseFilter(url.searchParams.get('filter'));
const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 40);
const [activityResult, unreadResult] = await Promise.allSettled([
api.GET('/api/dashboard/activity', { params: { query: { limit } } }),
api.GET('/api/notifications', {
params: { query: { read: false, page: 0, size: 20 } }
})
]);
let activityFeed: ActivityFeedItemDTO[] = [];
let unreadNotifications: NotificationDTO[] = [];
let loadError: string | null = null;
if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) {
activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? [];
} else if (activityResult.status === 'fulfilled') {
loadError = 'activity';
}
if (unreadResult.status === 'fulfilled' && unreadResult.value.response.ok) {
unreadNotifications = unreadResult.value.data?.content ?? [];
}
return {
filter,
activityFeed,
unreadNotifications,
loadError
};
}

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
import ChronikFuerDichBox from '$lib/components/chronik/ChronikFuerDichBox.svelte';
import ChronikFilterPills from '$lib/components/chronik/ChronikFilterPills.svelte';
import ChronikTimeline from '$lib/components/chronik/ChronikTimeline.svelte';
import ChronikEmptyState from '$lib/components/chronik/ChronikEmptyState.svelte';
import ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte';
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
interface Props {
data: {
filter: FilterValue;
activityFeed: ActivityFeedItemDTO[];
unreadNotifications: components['schemas']['NotificationDTO'][];
loadError: string | null;
};
}
const { data }: Props = $props();
// Mirror the current filter into a local state we can update on pill change.
// The effect syncs whenever the server-loaded filter changes (e.g. after goto).
// eslint-disable-next-line svelte/prefer-writable-derived -- we need this mutable for onFilterChange optimism before goto() resolves
let activeFilter = $state<FilterValue>('alle');
$effect(() => {
activeFilter = data.filter;
});
// Prefer the live SSE singleton for unread items so newly arriving mentions
// prepend without a reload. On first mount, seed from the server-loaded unread
// set if the singleton hasn't populated yet.
onMount(() => {
notificationStore.init();
});
onDestroy(() => {
notificationStore.destroy();
});
const liveUnread = $derived<NotificationItem[]>(
notificationStore.notifications.filter((n) => !n.read)
);
const seedUnread = $derived<NotificationItem[]>(
data.unreadNotifications
.filter((n): n is typeof n & { documentId: string; referenceId: string } =>
Boolean(n.documentId && n.referenceId)
)
.map((n) => ({
id: n.id,
type: n.type,
documentId: n.documentId,
documentTitle: n.documentTitle ?? null,
referenceId: n.referenceId,
annotationId: n.annotationId ?? null,
read: n.read,
createdAt: n.createdAt,
actorName: n.actorName ?? ''
}))
);
// If the singleton has any data (including zero after mark-all), trust it;
// otherwise fall back to the SSR-seeded unread set.
const unread = $derived<NotificationItem[]>(
notificationStore.notifications.length > 0 ? liveUnread : seedUnread
);
async function onFilterChange(v: FilterValue) {
activeFilter = v;
const url = new URL(page.url);
if (v === 'alle') url.searchParams.delete('filter');
else url.searchParams.set('filter', v);
await goto(`${url.pathname}${url.search}`, {
keepFocus: true,
noScroll: true,
replaceState: true
});
}
async function onMarkRead(n: NotificationItem) {
await notificationStore.markRead(n);
}
async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived<ActivityFeedItemDTO[]>(
(() => {
const merged = data.activityFeed;
switch (activeFilter) {
case 'alle':
return merged;
case 'fuer-dich':
return merged.filter((i) => i.kind === 'MENTION_CREATED' || i.youMentioned);
case 'hochgeladen':
return merged.filter((i) => i.kind === 'FILE_UPLOADED');
case 'transkription':
return merged.filter(
(i) =>
i.kind === 'TEXT_SAVED' ||
i.kind === 'BLOCK_REVIEWED' ||
i.kind === 'ANNOTATION_CREATED'
);
case 'kommentare':
return merged.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
}
})()
);
const isEmpty = $derived(displayFeed.length === 0);
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
data.activityFeed.length === 0 ? 'first-run' : 'filter-empty'
);
function retry() {
location.reload();
}
</script>
<svelte:head>
<title>{m.chronik_page_title()}</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<header class="mb-6 flex items-baseline justify-between">
<h1 class="font-serif text-2xl text-ink">{m.chronik_page_title()}</h1>
</header>
{#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} />
{:else}
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
<div class="mt-6">
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
</div>
{#if isEmpty}
<div class="mt-8">
<ChronikEmptyState variant={emptyVariant} />
</div>
{:else}
<ChronikTimeline items={displayFeed} />
{/if}
{/if}
</main>

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load } from './+page.server';
const mockApi = {
GET: vi.fn()
};
vi.mock('$lib/api.server', () => ({
createApiClient: () => mockApi
}));
function buildUrl(search = ''): URL {
return new URL(`http://localhost/chronik${search}`);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('chronik/load', () => {
it('requests the activity feed with a 40-item limit', async () => {
mockApi.GET.mockImplementation((path: string) => {
if (path === '/api/dashboard/activity') {
return Promise.resolve({ response: { ok: true }, data: [] });
}
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
});
await load({ fetch, url: buildUrl() } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40 } }
});
});
it('requests only unread notifications for Für-dich', async () => {
mockApi.GET.mockImplementation((path: string) => {
if (path === '/api/dashboard/activity') {
return Promise.resolve({ response: { ok: true }, data: [] });
}
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
});
await load({ fetch, url: buildUrl() } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
params: { query: { read: false, page: 0, size: 20 } }
});
});
it('returns the activity feed and unread notifications on success', async () => {
const feed = [{ kind: 'FILE_UPLOADED', documentId: 'd1' }];
const unread = [{ id: 'n1', type: 'MENTION' }];
mockApi.GET.mockImplementation((path: string) => {
if (path === '/api/dashboard/activity') {
return Promise.resolve({ response: { ok: true }, data: feed });
}
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
});
const result = await load({ fetch, url: buildUrl() } as never);
expect(result.activityFeed).toEqual(feed);
expect(result.unreadNotifications).toEqual(unread);
expect(result.filter).toBe('alle');
expect(result.loadError).toBeNull();
});
it('surfaces "activity" loadError when the dashboard endpoint returns non-ok', async () => {
mockApi.GET.mockImplementation((path: string) => {
if (path === '/api/dashboard/activity') {
return Promise.resolve({ response: { ok: false, status: 500 }, error: {} });
}
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
});
const result = await load({ fetch, url: buildUrl() } as never);
expect(result.loadError).toBe('activity');
expect(result.activityFeed).toEqual([]);
});
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
expect(validResult.filter).toBe('fuer-dich');
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never);
expect(invalidResult.filter).toBe('alle');
});
});

View File

@@ -1,35 +0,0 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ fetch, url }) => {
const api = createApiClient(fetch);
const type = url.searchParams.get('type') ?? undefined;
const readParam = url.searchParams.get('read');
const read = readParam !== null ? readParam === 'true' : undefined;
const result = await api.GET('/api/notifications', {
params: { query: { type: type as 'MENTION' | 'REPLY' | undefined, read, page: 0, size: 20 } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const page = result.data!;
const notifications = page.content ?? [];
const unreadCount = notifications.filter((n) => !n.read).length;
return { notifications, unreadCount, totalPages: page.totalPages ?? 1 };
};
export const actions: Actions = {
'mark-all': async ({ fetch }) => {
const api = createApiClient(fetch);
await api.POST('/api/notifications/read-all');
redirect(303, '/notifications');
}
};

View File

@@ -1,279 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
import { relativeTime, type NotificationItem } from '$lib/utils/notifications';
let { data } = $props();
let additionalNotifications = $state<NotificationItem[]>([]);
let loadMorePage = $state(1);
let isLoadingMore = $state(false);
const allNotifications = $derived([...data.notifications, ...additionalNotifications]);
const activeType = $derived(page.url.searchParams.get('type'));
const activeReadFilter = $derived(page.url.searchParams.get('read'));
const hasMore = $derived(loadMorePage < (data.totalPages ?? 1));
function setFilter(params: Record<string, string | null>) {
additionalNotifications = [];
loadMorePage = 1;
const url = new URL(page.url);
for (const [k, v] of Object.entries(params)) {
if (v === null) url.searchParams.delete(k);
else url.searchParams.set(k, v);
}
goto(url.toString());
}
async function loadMore() {
isLoadingMore = true;
try {
const typeParam = page.url.searchParams.get('type');
const readParam = page.url.searchParams.get('read');
let query = `page=${loadMorePage}&size=20`;
if (typeParam) query += `&type=${typeParam}`;
if (readParam !== null) query += `&read=${readParam}`;
const res = await fetch(`/api/notifications?${query}`);
if (res.ok) {
const json = await res.json();
additionalNotifications = [...additionalNotifications, ...(json.content ?? [])];
loadMorePage += 1;
}
} finally {
isLoadingMore = false;
}
}
async function navigateToNotification(n: NotificationItem) {
if (!n.read) {
await fetch(`/api/notifications/${n.id}/read`, { method: 'PATCH' });
}
const url = n.annotationId
? `/documents/${n.documentId}?commentId=${n.referenceId}&annotationId=${n.annotationId}`
: `/documents/${n.documentId}?commentId=${n.referenceId}`;
goto(url);
}
function typeBadgeLabel(type: NotificationItem['type']): string {
return type === 'MENTION' ? m.notification_filter_mention() : m.notification_filter_reply();
}
</script>
<svelte:head>
<title>{m.notification_history_heading()}</title>
</svelte:head>
<div class="min-h-screen bg-canvas">
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back link -->
<a
href="/"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-ink"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{m.btn_back_to_overview()}
</a>
<!-- Page header -->
<div class="mb-6 flex items-center justify-between">
<h1 class="font-serif text-2xl font-medium text-ink">
{m.notification_history_heading()}
</h1>
{#if data.unreadCount > 0}
<form method="POST" action="?/mark-all">
<button
type="submit"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
aria-label={m.notification_mark_all_read_aria()}
>
{m.notification_mark_all_read()}
</button>
</form>
{/if}
</div>
<!-- Filter pills -->
<div role="radiogroup" aria-label="Filter" class="mb-6 flex flex-wrap gap-2">
<!-- All -->
<button
role="radio"
aria-checked={activeType === null && activeReadFilter === null}
onclick={() => setFilter({ type: null, read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === null && activeReadFilter === null
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_all()}
</button>
<!-- Unread -->
<button
role="radio"
aria-checked={activeReadFilter === 'false'}
onclick={() => setFilter({ read: 'false', type: null })}
class={[
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeReadFilter === 'false'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_unread()}
{#if data.unreadCount > 0 && activeType === null && activeReadFilter === null}
<span
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 font-sans text-xs font-bold text-ink"
aria-hidden="true"
>
{data.unreadCount}
</span>
{/if}
</button>
<!-- Mention -->
<button
role="radio"
aria-checked={activeType === 'MENTION'}
onclick={() => setFilter({ type: 'MENTION', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'MENTION'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_mention()}
</button>
<!-- Reply -->
<button
role="radio"
aria-checked={activeType === 'REPLY'}
onclick={() => setFilter({ type: 'REPLY', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'REPLY'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_reply()}
</button>
</div>
<!-- Notification list or empty state -->
{#if allNotifications.length === 0}
<div class="flex flex-col items-center gap-3 py-20 text-center">
<svg
class="h-10 w-10 text-ink-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
<h2 class="font-serif text-lg font-semibold text-ink">
{m.notification_empty_history()}
</h2>
<p class="max-w-xs font-sans text-sm text-ink-2">
{m.notification_empty_history_body()}
</p>
</div>
{:else}
<ul
role="list"
class="divide-y divide-line rounded-sm border border-line bg-canvas shadow-sm"
>
{#each allNotifications as n (n.id)}
<li class="relative bg-surface">
<a
href="/documents/{n.documentId}"
role="row"
class={[
'flex min-h-14 flex-col justify-center border-l-[3px] px-4 py-4 md:px-6 md:py-5',
'transition-colors hover:bg-accent-bg',
n.read
? 'border-l-transparent'
: 'border-l-accent'
].join(' ')}
aria-label={m.notification_row_aria({
actor: n.actorName,
type: typeBadgeLabel(n.type),
title: n.documentTitle ?? '',
time: relativeTime(n.createdAt),
readState: n.read ? m.notification_read_state_read() : m.notification_read_state_unread()
})}
onclick={(e) => {
e.preventDefault();
navigateToNotification(n);
}}
>
<!-- Unread dot indicator -->
{#if !n.read}
<span
class="absolute top-4 right-4 h-2 w-2 rounded-full bg-accent md:right-6"
aria-hidden="true"
></span>
{/if}
<!-- Line 1: actor name + type badge -->
<div class="flex items-center gap-2">
<span class="font-serif font-semibold text-ink">{n.actorName}</span>
<span
class="rounded-sm bg-muted px-2 py-0.5 font-sans text-xs tracking-wide text-ink-2 uppercase"
>
{typeBadgeLabel(n.type)}
</span>
</div>
<!-- Line 2: document title -->
{#if n.documentTitle}
<p
class="mt-0.5 font-serif text-sm text-ink hover:underline hover:decoration-accent"
>
{n.documentTitle}
</p>
{/if}
<!-- Line 3: relative time -->
<p class="mt-1 font-sans text-sm text-ink-3">
{relativeTime(n.createdAt)}
</p>
</a>
</li>
{/each}
</ul>
{/if}
<!-- Load more -->
{#if hasMore}
<button
onclick={loadMore}
disabled={isLoadingMore}
class="mt-6 w-full rounded-sm border border-line py-3 text-sm font-medium text-ink-2 transition-colors hover:bg-canvas disabled:opacity-50"
>
{isLoadingMore ? '…' : m.notification_load_more()}
</button>
{/if}
</div>
</div>

View File

@@ -1,136 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
import { load, actions } from './+page.server';
import { createApiClient } from '$lib/api.server';
beforeEach(() => vi.clearAllMocks());
function makeUrl(params: Record<string, string> = {}) {
const url = new URL('http://localhost/notifications');
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url;
}
// ─── load ─────────────────────────────────────────────────────────────────────
describe('notifications page load', () => {
it('returns notifications and unreadCount from API response', async () => {
const mockGet = vi.fn().mockResolvedValueOnce({
response: { ok: true },
data: {
content: [
{ id: 'n1', read: false },
{ id: 'n2', read: true },
{ id: 'n3', read: false }
],
totalElements: 3,
totalPages: 1,
number: 0
}
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.notifications).toHaveLength(3);
expect(result.unreadCount).toBe(2);
});
it('passes type param to API when ?type=MENTION is in URL', async () => {
const mockGet = vi.fn().mockResolvedValueOnce({
response: { ok: true },
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ url: makeUrl({ type: 'MENTION' }), fetch: vi.fn() as unknown as typeof fetch });
const queryParams = mockGet.mock.calls[0][1].params.query;
expect(queryParams.type).toBe('MENTION');
});
it('passes read=false to API when ?read=false is in URL', async () => {
const mockGet = vi.fn().mockResolvedValueOnce({
response: { ok: true },
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ url: makeUrl({ read: 'false' }), fetch: vi.fn() as unknown as typeof fetch });
const queryParams = mockGet.mock.calls[0][1].params.query;
expect(queryParams.read).toBe(false);
});
it('passes no filter params when no search params present', async () => {
const mockGet = vi.fn().mockResolvedValueOnce({
response: { ok: true },
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
const queryParams = mockGet.mock.calls[0][1].params.query;
expect(queryParams.type).toBeUndefined();
expect(queryParams.read).toBeUndefined();
});
it('calls the API exactly once — no separate round-trip for unreadCount', async () => {
const mockGet = vi.fn().mockResolvedValueOnce({
response: { ok: true },
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(mockGet).toHaveBeenCalledTimes(1);
});
it('throws 401 error when API returns 401', async () => {
const mockGet = vi.fn().mockResolvedValueOnce({
response: { ok: false, status: 401 },
data: null
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await expect(
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
).rejects.toMatchObject({ status: 401 });
});
});
// ─── mark-all action ──────────────────────────────────────────────────────────
describe('notifications mark-all action', () => {
it('calls POST /api/notifications/read-all and redirects', async () => {
const mockPost = vi.fn().mockResolvedValueOnce({ response: { ok: true } });
vi.mocked(createApiClient).mockReturnValue({ POST: mockPost } as ReturnType<
typeof createApiClient
>);
const markAll = actions['mark-all'] as (ctx: { fetch: typeof fetch }) => Promise<never>;
await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({
location: '/notifications'
});
expect(mockPost).toHaveBeenCalledTimes(1);
});
});

View File

@@ -102,10 +102,7 @@ const hasEmail = $derived(!!data.user?.email);
</form>
<div class="mt-4 border-t border-line pt-4">
<a
href="/notifications"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<a href="/chronik" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
{m.notification_history_view_link()}
</a>
</div>