feat: unify /notifications and dashboard activity feed into a /chronik page #288
@@ -12,4 +12,6 @@ public interface ActivityFeedRow {
|
||||
UUID getDocumentId();
|
||||
Instant getHappenedAt();
|
||||
boolean isYouMentioned();
|
||||
int getCount();
|
||||
Instant getHappenedAtUntil();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
59
docs/adr/003-chronik-unified-activity-feed.md
Normal file
59
docs/adr/003-chronik-unified-activity-feed.md
Normal 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.
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
|
||||
@@ -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 →"
|
||||
}
|
||||
|
||||
@@ -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 →"
|
||||
}
|
||||
|
||||
@@ -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 →"
|
||||
}
|
||||
|
||||
@@ -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:02–14: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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
92
frontend/src/lib/components/chronik/ChronikEmptyState.svelte
Normal file
92
frontend/src/lib/components/chronik/ChronikEmptyState.svelte
Normal 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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/components/chronik/ChronikErrorCard.svelte
Normal file
46
frontend/src/lib/components/chronik/ChronikErrorCard.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
148
frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte
Normal file
148
frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
174
frontend/src/lib/components/chronik/ChronikRow.svelte
Normal file
174
frontend/src/lib/components/chronik/ChronikRow.svelte
Normal 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"
|
||||
>
|
||||
„…“
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="mt-0.5 font-sans text-xs text-ink-3">{timeLabel}</p>
|
||||
</div>
|
||||
</a>
|
||||
154
frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts
Normal file
154
frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
frontend/src/lib/components/chronik/ChronikTimeline.svelte
Normal file
66
frontend/src/lib/components/chronik/ChronikTimeline.svelte
Normal 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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
108
frontend/src/lib/stores/notifications.svelte.spec.ts
Normal file
108
frontend/src/lib/stores/notifications.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
frontend/src/lib/stores/notifications.svelte.ts
Normal file
108
frontend/src/lib/stores/notifications.svelte.ts
Normal 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
|
||||
};
|
||||
50
frontend/src/lib/utils/date-buckets.spec.ts
Normal file
50
frontend/src/lib/utils/date-buckets.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
frontend/src/lib/utils/date-buckets.ts
Normal file
35
frontend/src/lib/utils/date-buckets.ts
Normal 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';
|
||||
}
|
||||
54
frontend/src/routes/chronik/+page.server.ts
Normal file
54
frontend/src/routes/chronik/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
154
frontend/src/routes/chronik/+page.svelte
Normal file
154
frontend/src/routes/chronik/+page.svelte
Normal 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>
|
||||
93
frontend/src/routes/chronik/page.server.spec.ts
Normal file
93
frontend/src/routes/chronik/page.server.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user