@jant/core 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/app.js +23 -5
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -5,28 +5,23 @@ import type {
5
5
  TimelineCardProps,
6
6
  ThreadPreviewProps,
7
7
  TimelineFeedProps,
8
- PostType,
8
+ Format,
9
9
  HomePageProps,
10
10
  } from "../../types.js";
11
11
  import type { FC } from "hono/jsx";
12
12
 
13
13
  // Create simple mock components for testing (avoids importing .tsx files with i18n)
14
14
  const MockNoteCard: FC<TimelineCardProps> = () => null;
15
- const MockArticleCard: FC<TimelineCardProps> = () => null;
16
15
  const MockLinkCard: FC<TimelineCardProps> = () => null;
17
16
  const MockQuoteCard: FC<TimelineCardProps> = () => null;
18
- const MockImageCard: FC<TimelineCardProps> = () => null;
19
17
  const MockThreadPreview: FC<ThreadPreviewProps> = () => null;
20
18
  const MockTimelineFeed: FC<TimelineFeedProps> = () => null;
21
19
  const MockHomePage: FC<HomePageProps> = () => null;
22
20
 
23
- const DEFAULT_CARD_MAP: Record<PostType, FC<TimelineCardProps>> = {
21
+ const DEFAULT_CARD_MAP: Record<Format, FC<TimelineCardProps>> = {
24
22
  note: MockNoteCard,
25
- article: MockArticleCard,
26
23
  link: MockLinkCard,
27
24
  quote: MockQuoteCard,
28
- image: MockImageCard,
29
- page: MockNoteCard,
30
25
  };
31
26
 
32
27
  describe("theme-components", () => {
@@ -35,12 +30,6 @@ describe("theme-components", () => {
35
30
  expect(resolveCardComponent("note", DEFAULT_CARD_MAP)).toBe(MockNoteCard);
36
31
  });
37
32
 
38
- it("returns default ArticleCard for article type", () => {
39
- expect(resolveCardComponent("article", DEFAULT_CARD_MAP)).toBe(
40
- MockArticleCard,
41
- );
42
- });
43
-
44
33
  it("returns default LinkCard for link type", () => {
45
34
  expect(resolveCardComponent("link", DEFAULT_CARD_MAP)).toBe(MockLinkCard);
46
35
  });
@@ -51,16 +40,6 @@ describe("theme-components", () => {
51
40
  );
52
41
  });
53
42
 
54
- it("returns default ImageCard for image type", () => {
55
- expect(resolveCardComponent("image", DEFAULT_CARD_MAP)).toBe(
56
- MockImageCard,
57
- );
58
- });
59
-
60
- it("returns NoteCard as fallback for page type", () => {
61
- expect(resolveCardComponent("page", DEFAULT_CARD_MAP)).toBe(MockNoteCard);
62
- });
63
-
64
43
  it("returns theme override when provided", () => {
65
44
  const CustomNote: FC<TimelineCardProps> = () => null;
66
45
  const overrides: ThemeComponents = { NoteCard: CustomNote };
@@ -71,8 +50,8 @@ describe("theme-components", () => {
71
50
 
72
51
  it("returns default when theme has no override for type", () => {
73
52
  const overrides: ThemeComponents = {};
74
- expect(resolveCardComponent("article", DEFAULT_CARD_MAP, overrides)).toBe(
75
- MockArticleCard,
53
+ expect(resolveCardComponent("link", DEFAULT_CARD_MAP, overrides)).toBe(
54
+ MockLinkCard,
76
55
  );
77
56
  });
78
57
  });
@@ -4,6 +4,8 @@ import {
4
4
  isWithinMonth,
5
5
  toISOString,
6
6
  formatDate,
7
+ formatTime,
8
+ formatRelativeTime,
7
9
  formatYearMonth,
8
10
  } from "../time.js";
9
11
 
@@ -91,6 +93,66 @@ describe("formatDate", () => {
91
93
  });
92
94
  });
93
95
 
96
+ describe("formatTime", () => {
97
+ it("formats as HH:MM in 24-hour format", () => {
98
+ // 2024-01-15T12:30:00Z = 1705321800
99
+ expect(formatTime(1705321800)).toBe("12:30");
100
+ });
101
+
102
+ it("zero-pads hours and minutes", () => {
103
+ // 2024-02-01T00:00:00Z = 1706745600
104
+ expect(formatTime(1706745600)).toBe("00:00");
105
+ });
106
+
107
+ it("formats evening time correctly", () => {
108
+ // 2024-02-01T23:05:00Z = 1706828700
109
+ expect(formatTime(1706828700)).toBe("23:05");
110
+ });
111
+
112
+ it("formats single-digit hour with padding", () => {
113
+ // 2024-02-01T09:07:00Z = 1706778420
114
+ expect(formatTime(1706778420)).toBe("09:07");
115
+ });
116
+ });
117
+
118
+ describe("formatRelativeTime", () => {
119
+ afterEach(() => {
120
+ vi.restoreAllMocks();
121
+ });
122
+
123
+ it("returns '1m' for timestamps less than 60 seconds ago", () => {
124
+ expect(formatRelativeTime(now() - 10)).toBe("1m");
125
+ expect(formatRelativeTime(now() - 59)).toBe("1m");
126
+ });
127
+
128
+ it("returns minutes for timestamps under 1 hour", () => {
129
+ expect(formatRelativeTime(now() - 60)).toBe("1m");
130
+ expect(formatRelativeTime(now() - 300)).toBe("5m");
131
+ expect(formatRelativeTime(now() - 3540)).toBe("59m");
132
+ });
133
+
134
+ it("returns hours for timestamps under 24 hours", () => {
135
+ expect(formatRelativeTime(now() - 3600)).toBe("1h");
136
+ expect(formatRelativeTime(now() - 7200)).toBe("2h");
137
+ expect(formatRelativeTime(now() - 82800)).toBe("23h");
138
+ });
139
+
140
+ it("returns days for timestamps up to 7 days", () => {
141
+ expect(formatRelativeTime(now() - 86400)).toBe("1d");
142
+ expect(formatRelativeTime(now() - 3 * 86400)).toBe("3d");
143
+ expect(formatRelativeTime(now() - 7 * 86400)).toBe("7d");
144
+ });
145
+
146
+ it("returns 'MMM D' for timestamps older than 7 days", () => {
147
+ // Use a fixed timestamp to get a predictable date
148
+ // Feb 1, 2024 00:00:00 UTC
149
+ const feb1 = 1706745600;
150
+ // Mock now() to return Feb 16, 2024
151
+ vi.spyOn(Date, "now").mockReturnValue((feb1 + 15 * 86400) * 1000);
152
+ expect(formatRelativeTime(feb1)).toBe("Feb 1");
153
+ });
154
+ });
155
+
94
156
  describe("formatYearMonth", () => {
95
157
  it("formats as YYYY-MM", () => {
96
158
  expect(formatYearMonth(1706745600)).toBe("2024-02");
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Timeline API Tests
2
+ * Timeline Data Assembly Tests
3
3
  *
4
4
  * Tests the timeline data assembly logic via the service layer.
5
5
  * The actual route handler renders JSX components which require the Lingui SWC
6
6
  * plugin (not available in vitest). We test the underlying service operations
7
- * that power the timeline API instead.
7
+ * that power the timeline instead.
8
8
  */
9
9
 
10
10
  import { describe, it, expect, beforeEach } from "vitest";
11
- import { createTestDatabase } from "../../../__tests__/helpers/db.js";
12
- import { createPostService } from "../../../services/post.js";
13
- import { createMediaService } from "../../../services/media.js";
14
- import { buildMediaMap } from "../../../lib/media-helpers.js";
15
- import type { Database } from "../../../db/index.js";
16
- import type { PostWithMedia } from "../../../types.js";
11
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
12
+ import { createPostService } from "../../services/post.js";
13
+ import { createMediaService } from "../../services/media.js";
14
+ import { buildMediaMap } from "../media-helpers.js";
15
+ import { groupByDate } from "../timeline.js";
16
+ import type { Database } from "../../db/index.js";
17
+ import type { PostWithMedia, TimelineItemView } from "../../types.js";
17
18
 
18
19
  describe("Timeline data assembly", () => {
19
20
  let db: Database;
@@ -29,15 +30,13 @@ describe("Timeline data assembly", () => {
29
30
 
30
31
  it("assembles timeline items with media attachments", async () => {
31
32
  const post = await postService.create({
32
- type: "note",
33
- content: "Hello",
34
- visibility: "featured",
33
+ format: "note",
34
+ body: "Hello",
35
35
  });
36
36
 
37
37
  const posts = await postService.list({
38
- visibility: ["featured", "quiet"],
38
+ status: "published",
39
39
  excludeReplies: true,
40
- excludeTypes: ["page"],
41
40
  limit: 21,
42
41
  });
43
42
 
@@ -60,25 +59,23 @@ describe("Timeline data assembly", () => {
60
59
 
61
60
  it("identifies thread roots and builds thread previews", async () => {
62
61
  const root = await postService.create({
63
- type: "note",
64
- content: "Thread root",
65
- visibility: "featured",
62
+ format: "note",
63
+ body: "Thread root",
66
64
  });
67
65
  await postService.create({
68
- type: "note",
69
- content: "Reply 1",
66
+ format: "note",
67
+ body: "Reply 1",
70
68
  replyToId: root.id,
71
69
  });
72
70
  await postService.create({
73
- type: "note",
74
- content: "Reply 2",
71
+ format: "note",
72
+ body: "Reply 2",
75
73
  replyToId: root.id,
76
74
  });
77
75
 
78
76
  const posts = await postService.list({
79
- visibility: ["featured", "quiet"],
77
+ status: "published",
80
78
  excludeReplies: true,
81
- excludeTypes: ["page"],
82
79
  limit: 21,
83
80
  });
84
81
 
@@ -96,7 +93,7 @@ describe("Timeline data assembly", () => {
96
93
  const threadPreviews = await postService.getThreadPreviews(threadRootIds);
97
94
  const replies = threadPreviews.get(root.id);
98
95
  expect(replies).toHaveLength(2);
99
- expect(replies?.[0]?.content).toBe("Reply 1");
96
+ expect(replies?.[0]?.body).toBe("Reply 1");
100
97
 
101
98
  // Assemble items
102
99
  const rawMediaMap = await mediaService.getByPostIds(postIds);
@@ -133,50 +130,25 @@ describe("Timeline data assembly", () => {
133
130
  expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
134
131
  });
135
132
 
136
- it("excludes pages from timeline", async () => {
137
- await postService.create({
138
- type: "note",
139
- content: "A note",
140
- visibility: "quiet",
141
- });
142
- await postService.create({
143
- type: "page",
144
- content: "A page",
145
- visibility: "quiet",
146
- });
147
-
148
- const posts = await postService.list({
149
- visibility: ["featured", "quiet"],
150
- excludeReplies: true,
151
- excludeTypes: ["page"],
152
- limit: 21,
153
- });
154
-
155
- expect(posts).toHaveLength(1);
156
- expect(posts[0]?.type).toBe("note");
157
- });
158
-
159
133
  it("excludes replies from top-level list", async () => {
160
134
  const root = await postService.create({
161
- type: "note",
162
- content: "Root",
163
- visibility: "quiet",
135
+ format: "note",
136
+ body: "Root",
164
137
  });
165
138
  await postService.create({
166
- type: "note",
167
- content: "Reply",
139
+ format: "note",
140
+ body: "Reply",
168
141
  replyToId: root.id,
169
142
  });
170
143
 
171
144
  const posts = await postService.list({
172
- visibility: ["featured", "quiet"],
145
+ status: "published",
173
146
  excludeReplies: true,
174
- excludeTypes: ["page"],
175
147
  limit: 21,
176
148
  });
177
149
 
178
150
  expect(posts).toHaveLength(1);
179
- expect(posts[0]?.content).toBe("Root");
151
+ expect(posts[0]?.body).toBe("Root");
180
152
  });
181
153
 
182
154
  it("supports cursor pagination for load more", async () => {
@@ -184,9 +156,8 @@ describe("Timeline data assembly", () => {
184
156
  for (let i = 0; i < 5; i++) {
185
157
  posts.push(
186
158
  await postService.create({
187
- type: "note",
188
- content: `Post ${i}`,
189
- visibility: "quiet",
159
+ format: "note",
160
+ body: `Post ${i}`,
190
161
  publishedAt: 1000 + i,
191
162
  }),
192
163
  );
@@ -194,9 +165,8 @@ describe("Timeline data assembly", () => {
194
165
 
195
166
  // First page
196
167
  const page1 = await postService.list({
197
- visibility: ["featured", "quiet"],
168
+ status: "published",
198
169
  excludeReplies: true,
199
- excludeTypes: ["page"],
200
170
  limit: 3,
201
171
  });
202
172
  expect(page1).toHaveLength(3);
@@ -205,9 +175,8 @@ describe("Timeline data assembly", () => {
205
175
  const lastPost = page1[page1.length - 1];
206
176
  expect(lastPost).toBeDefined();
207
177
  const page2 = await postService.list({
208
- visibility: ["featured", "quiet"],
178
+ status: "published",
209
179
  excludeReplies: true,
210
- excludeTypes: ["page"],
211
180
  limit: 3,
212
181
  cursor: lastPost?.id,
213
182
  });
@@ -218,18 +187,16 @@ describe("Timeline data assembly", () => {
218
187
  it("correctly determines hasMore flag", async () => {
219
188
  for (let i = 0; i < 3; i++) {
220
189
  await postService.create({
221
- type: "note",
222
- content: `Post ${i}`,
223
- visibility: "quiet",
190
+ format: "note",
191
+ body: `Post ${i}`,
224
192
  });
225
193
  }
226
194
 
227
195
  // Request limit + 1 to check for more
228
196
  const pageSize = 2;
229
197
  const posts = await postService.list({
230
- visibility: ["featured", "quiet"],
198
+ status: "published",
231
199
  excludeReplies: true,
232
- excludeTypes: ["page"],
233
200
  limit: pageSize + 1,
234
201
  });
235
202
 
@@ -240,3 +207,78 @@ describe("Timeline data assembly", () => {
240
207
  expect(displayPosts).toHaveLength(2);
241
208
  });
242
209
  });
210
+
211
+ describe("groupByDate", () => {
212
+ function makeItem(dateStr: string, formatted: string): TimelineItemView {
213
+ return {
214
+ post: {
215
+ id: 1,
216
+ permalink: "/p/1",
217
+ format: "note",
218
+ status: "published",
219
+ featured: true,
220
+ pinned: false,
221
+ publishedAt: `${dateStr}T12:00:00.000Z`,
222
+ publishedAtFormatted: formatted,
223
+ publishedAtTime: "12:00",
224
+ publishedAtRelative: "1d",
225
+ updatedAt: `${dateStr}T12:00:00.000Z`,
226
+ media: [],
227
+ },
228
+ };
229
+ }
230
+
231
+ it("returns empty array for empty input", () => {
232
+ expect(groupByDate([])).toEqual([]);
233
+ });
234
+
235
+ it("groups items by YYYY-MM-DD date key", () => {
236
+ const items = [
237
+ makeItem("2024-02-01", "Feb 1, 2024"),
238
+ makeItem("2024-02-01", "Feb 1, 2024"),
239
+ makeItem("2024-02-02", "Feb 2, 2024"),
240
+ ];
241
+
242
+ const groups = groupByDate(items);
243
+ expect(groups).toHaveLength(2);
244
+ expect(groups[0]?.dateKey).toBe("2024-02-01");
245
+ expect(groups[0]?.label).toBe("Feb 1, 2024");
246
+ expect(groups[0]?.items).toHaveLength(2);
247
+ expect(groups[1]?.dateKey).toBe("2024-02-02");
248
+ expect(groups[1]?.items).toHaveLength(1);
249
+ });
250
+
251
+ it("creates separate groups for non-contiguous same dates", () => {
252
+ const items = [
253
+ makeItem("2024-02-01", "Feb 1, 2024"),
254
+ makeItem("2024-02-02", "Feb 2, 2024"),
255
+ makeItem("2024-02-01", "Feb 1, 2024"),
256
+ ];
257
+
258
+ const groups = groupByDate(items);
259
+ expect(groups).toHaveLength(3);
260
+ expect(groups[0]?.dateKey).toBe("2024-02-01");
261
+ expect(groups[1]?.dateKey).toBe("2024-02-02");
262
+ expect(groups[2]?.dateKey).toBe("2024-02-01");
263
+ });
264
+
265
+ it("handles a single item", () => {
266
+ const items = [makeItem("2024-06-15", "Jun 15, 2024")];
267
+ const groups = groupByDate(items);
268
+ expect(groups).toHaveLength(1);
269
+ expect(groups[0]?.dateKey).toBe("2024-06-15");
270
+ expect(groups[0]?.items).toHaveLength(1);
271
+ });
272
+
273
+ it("uses the first item's formatted date as the group label", () => {
274
+ const items = [
275
+ makeItem("2024-03-10", "Mar 10, 2024"),
276
+ makeItem("2024-03-10", "March 10"),
277
+ ];
278
+
279
+ const groups = groupByDate(items);
280
+ expect(groups).toHaveLength(1);
281
+ // Label comes from first item in the group
282
+ expect(groups[0]?.label).toBe("Mar 10, 2024");
283
+ });
284
+ });