Skip to content

Commit 5fbc02d

Browse files
authored
Merge pull request #2658 from freedomofpress/micahflee/last-source-activity
Implement "Last source activity"
2 parents bbc39a1 + eb5c370 commit 5fbc02d

File tree

7 files changed

+248
-57
lines changed

7 files changed

+248
-57
lines changed

app/src/renderer/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"messagePlaceholder": "Message {{designation}}..."
1818
},
1919
"itemEncrypted": "Message is encrypted...",
20+
"lastSourceActivity": "Last source activity",
2021
"you": "You",
2122
"unknown": "Unknown"
2223
},

app/src/renderer/locales/fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"messagePlaceholder": "Message {{designation}}..."
1818
},
1919
"itemEncrypted": "Message chiffré...",
20+
"lastSourceActivity": "Dernière activité de la source",
2021
"you": "Vous",
2122
"unknown": "Inconnu"
2223
},

app/src/renderer/utils.test.ts

Lines changed: 141 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import type { TFunction } from "i18next";
33
import {
4-
formatDate,
4+
formatDateShort,
5+
formatDateLong,
56
getInitials,
67
toTitleCase,
78
formatJournalistName,
@@ -22,7 +23,7 @@ describe("utils", () => {
2223
vi.useRealTimers();
2324
});
2425

25-
describe("formatDate", () => {
26+
describe("formatDateShort", () => {
2627
const mockT = vi.fn((key: string) => {
2728
switch (key) {
2829
case "yesterday":
@@ -39,19 +40,19 @@ describe("utils", () => {
3940
describe("today formatting", () => {
4041
it("should format today's date as time only", () => {
4142
const todayDate = "2024-01-15T14:30:00Z";
42-
const result = formatDate(todayDate, "en-US", mockT);
43+
const result = formatDateShort(todayDate, "en-US", mockT);
4344
expect(result).toMatch(/\d{1,2}:\d{2}.*[AP]M/); // Matches time format like "2:30 PM"
4445
});
4546

4647
it("should format today's date with different locale", () => {
4748
const todayDate = "2024-01-15T09:15:00Z";
48-
const result = formatDate(todayDate, "fr-FR", mockT);
49+
const result = formatDateShort(todayDate, "fr-FR", mockT);
4950
expect(result).toMatch(/\d{1,2}:\d{2}/); // French format without AM/PM
5051
});
5152

5253
it("should handle today's date at midnight", () => {
5354
const midnightDate = "2024-01-15T00:00:00Z";
54-
const result = formatDate(midnightDate, "en-US", mockT);
55+
const result = formatDateShort(midnightDate, "en-US", mockT);
5556
// Midnight UTC might be displayed as yesterday in local time
5657
expect(result).toMatch(/12:00.*AM|Yesterday/);
5758
});
@@ -60,72 +61,76 @@ describe("utils", () => {
6061
describe("yesterday formatting", () => {
6162
it("should format yesterday's date as 'Yesterday'", () => {
6263
const yesterdayDate = "2024-01-14T15:30:00Z";
63-
const result = formatDate(yesterdayDate, "en-US", mockT);
64+
const result = formatDateShort(yesterdayDate, "en-US", mockT);
6465
expect(result).toBe("Yesterday");
6566
expect(mockT).toHaveBeenCalledWith("yesterday");
6667
});
6768

6869
it("should handle yesterday at different times", () => {
6970
const yesterdayMorning = "2024-01-14T08:00:00Z";
70-
const result = formatDate(yesterdayMorning, "en-US", mockT);
71+
const result = formatDateShort(yesterdayMorning, "en-US", mockT);
7172
expect(result).toBe("Yesterday");
7273
});
7374
});
7475

7576
describe("this year formatting", () => {
7677
it("should format dates in current year as month and day", () => {
7778
const currentYearDate = "2024-03-10T10:00:00Z";
78-
const result = formatDate(currentYearDate, "en-US", mockT);
79+
const result = formatDateShort(currentYearDate, "en-US", mockT);
7980
expect(result).toBe("Mar 10");
8081
});
8182

8283
it("should format dates with different locale", () => {
8384
const currentYearDate = "2024-12-25T10:00:00Z";
84-
const result = formatDate(currentYearDate, "fr-FR", mockT);
85+
const result = formatDateShort(currentYearDate, "fr-FR", mockT);
8586
expect(result).toMatch(/déc\.?\s*25|25\s*déc/i); // French format
8687
});
8788

8889
it("should handle single digit days", () => {
8990
const singleDigitDay = "2024-05-05T10:00:00Z";
90-
const result = formatDate(singleDigitDay, "en-US", mockT);
91+
const result = formatDateShort(singleDigitDay, "en-US", mockT);
9192
expect(result).toBe("May 5");
9293
});
9394
});
9495

9596
describe("previous years formatting", () => {
9697
it("should format dates from previous years with year", () => {
9798
const previousYearDate = "2023-06-15T10:00:00Z";
98-
const result = formatDate(previousYearDate, "en-US", mockT);
99+
const result = formatDateShort(previousYearDate, "en-US", mockT);
99100
expect(result).toBe("Jun 15, 2023");
100101
});
101102

102103
it("should format very old dates", () => {
103104
const oldDate = "2020-01-01T10:00:00Z";
104-
const result = formatDate(oldDate, "en-US", mockT);
105+
const result = formatDateShort(oldDate, "en-US", mockT);
105106
expect(result).toBe("Jan 1, 2020");
106107
});
107108

108109
it("should handle different locale for previous years", () => {
109110
const previousYearDate = "2022-11-30T10:00:00Z";
110-
const result = formatDate(previousYearDate, "de-DE", mockT);
111+
const result = formatDateShort(previousYearDate, "de-DE", mockT);
111112
// German format: "30. Nov. 2022"
112113
expect(result).toMatch(/30\.?\s*Nov\.?\s*2022/i);
113114
});
114115
});
115116

116117
describe("locale normalization", () => {
117118
it("should handle POSIX locale format", () => {
118-
const result = formatDate("2024-03-10T10:00:00Z", "en_US.UTF-8", mockT);
119+
const result = formatDateShort(
120+
"2024-03-10T10:00:00Z",
121+
"en_US.UTF-8",
122+
mockT,
123+
);
119124
expect(result).toBe("Mar 10");
120125
});
121126

122127
it("should handle locale with underscore", () => {
123-
const result = formatDate("2024-03-10T10:00:00Z", "fr_FR", mockT);
128+
const result = formatDateShort("2024-03-10T10:00:00Z", "fr_FR", mockT);
124129
expect(result).toMatch(/mars?\s*10|10\s*mars?/i);
125130
});
126131

127132
it("should fallback to language code for invalid locale", () => {
128-
const result = formatDate(
133+
const result = formatDateShort(
129134
"2024-03-10T10:00:00Z",
130135
"invalid_LOCALE",
131136
mockT,
@@ -134,21 +139,21 @@ describe("utils", () => {
134139
});
135140

136141
it("should fallback to 'en' for completely invalid locale", () => {
137-
const result = formatDate("2024-03-10T10:00:00Z", "xxx", mockT);
142+
const result = formatDateShort("2024-03-10T10:00:00Z", "xxx", mockT);
138143
expect(result).toBeTruthy(); // Should fallback to English
139144
});
140145

141146
it("should use browser default when locale is empty", () => {
142-
const result = formatDate("2024-03-10T10:00:00Z", "", mockT);
147+
const result = formatDateShort("2024-03-10T10:00:00Z", "", mockT);
143148
expect(result).toBeTruthy();
144149
});
145150

146151
it("should handle null/undefined locale", () => {
147152
// @ts-expect-error - Testing null locale
148-
const resultNull = formatDate("2024-03-10T10:00:00Z", null, mockT);
153+
const resultNull = formatDateShort("2024-03-10T10:00:00Z", null, mockT);
149154
expect(resultNull).toBeTruthy();
150155

151-
const resultUndefined = formatDate(
156+
const resultUndefined = formatDateShort(
152157
"2024-03-10T10:00:00Z",
153158
// @ts-expect-error - Testing undefined locale
154159
undefined,
@@ -160,38 +165,148 @@ describe("utils", () => {
160165

161166
describe("edge cases and error handling", () => {
162167
it("should handle invalid date strings gracefully", () => {
163-
const result = formatDate("invalid-date", "en-US", mockT);
168+
const result = formatDateShort("invalid-date", "en-US", mockT);
164169
expect(result).toMatch(/Invalid Date|NaN/i);
165170
});
166171

167172
it("should handle empty date string", () => {
168-
const result = formatDate("", "en-US", mockT);
173+
const result = formatDateShort("", "en-US", mockT);
169174
expect(result).toMatch(/Invalid Date|NaN/i);
170175
});
171176

172177
it("should handle dates far in the future", () => {
173178
const futureDate = "2030-12-31T23:59:59Z";
174-
const result = formatDate(futureDate, "en-US", mockT);
179+
const result = formatDateShort(futureDate, "en-US", mockT);
175180
expect(result).toBe("Dec 31, 2030");
176181
});
177182

178183
it("should handle leap year dates", () => {
179184
const leapYearDate = "2024-02-29T10:00:00Z";
180-
const result = formatDate(leapYearDate, "en-US", mockT);
185+
const result = formatDateShort(leapYearDate, "en-US", mockT);
181186
expect(result).toBe("Feb 29");
182187
});
183188

184189
it("should handle different timezone inputs", () => {
185190
const utcDate = "2024-03-10T10:00:00Z";
186191
const offsetDate = "2024-03-10T10:00:00+05:00";
187192

188-
const resultUTC = formatDate(utcDate, "en-US", mockT);
189-
const resultOffset = formatDate(offsetDate, "en-US", mockT);
193+
const resultUTC = formatDateShort(utcDate, "en-US", mockT);
194+
const resultOffset = formatDateShort(offsetDate, "en-US", mockT);
190195

191196
expect(resultUTC).toBeTruthy();
192197
expect(resultOffset).toBeTruthy();
193198
});
194199
});
200+
201+
describe("UTC assumption", () => {
202+
it("should treat timestamp without timezone as UTC", () => {
203+
const timestampWithoutTZ = "2024-08-29T21:13:10.760877";
204+
const timestampWithTZ = "2024-08-29T21:13:10.760877Z";
205+
206+
const resultWithoutTZ = formatDateShort(
207+
timestampWithoutTZ,
208+
"en-US",
209+
mockT,
210+
);
211+
const resultWithTZ = formatDateShort(timestampWithTZ, "en-US", mockT);
212+
213+
// Both should produce the same result since we treat no-TZ as UTC
214+
expect(resultWithoutTZ).toBe(resultWithTZ);
215+
});
216+
});
217+
});
218+
219+
describe("formatDateLong", () => {
220+
it("should format date with full timestamp including time and timezone", () => {
221+
const dateString = "2024-03-10T14:30:45Z";
222+
const result = formatDateLong(dateString, "en-US");
223+
224+
// Should include year, month, day, time, and timezone
225+
expect(result).toMatch(/March.*10.*2024/); // Date parts with full month name
226+
expect(result).toMatch(/\d{1,2}:\d{2}(?!\d)/); // Time without seconds or leading zeros on hours
227+
expect(result).toMatch(/[AP]M/); // AM/PM
228+
expect(result).toMatch(/[A-Z]{3,4}/); // Timezone abbreviation
229+
});
230+
231+
it("should format date with different locale", () => {
232+
const dateString = "2024-12-25T09:15:30Z";
233+
const result = formatDateLong(dateString, "fr-FR");
234+
235+
// French format should have full month name
236+
expect(result).toMatch(/décembre|December/i); // French December (full name)
237+
expect(result).toMatch(/25/); // Day
238+
expect(result).toMatch(/2024/); // Year
239+
expect(result).toMatch(/\d{1,2}:\d{2}(?!\d)/); // Time without seconds
240+
});
241+
242+
it("should handle POSIX locale format", () => {
243+
const dateString = "2024-06-15T18:45:12Z";
244+
const result = formatDateLong(dateString, "en_US.UTF-8");
245+
246+
expect(result).toMatch(/June.*15.*2024/);
247+
expect(result).toMatch(/\d{1,2}:\d{2}(?!\d)/); // Time without seconds
248+
});
249+
250+
it("should fallback gracefully for invalid locale", () => {
251+
const dateString = "2024-01-01T00:00:00Z";
252+
const result = formatDateLong(dateString, "invalid_locale");
253+
254+
// Should still produce a valid date string (may show different date due to timezone conversion)
255+
expect(result).toMatch(/January.*1.*2024|December.*31.*2023/); // Could be either due to timezone (full month names)
256+
expect(result).toMatch(/\d{1,2}:\d{2}(?!\d)/); // Time without seconds
257+
});
258+
259+
it("should handle different timezone inputs", () => {
260+
const utcDate = "2024-05-20T10:30:15Z";
261+
const offsetDate = "2024-05-20T10:30:15+02:00";
262+
263+
const resultUTC = formatDateLong(utcDate, "en-US");
264+
const resultOffset = formatDateLong(offsetDate, "en-US");
265+
266+
// Both should be valid timestamps (times will be converted to local timezone)
267+
expect(resultUTC).toMatch(/May.*20.*2024/);
268+
expect(resultUTC).toMatch(/\d{1,2}:\d{2}(?!\d)/);
269+
expect(resultOffset).toMatch(/May.*20.*2024/);
270+
expect(resultOffset).toMatch(/\d{1,2}:\d{2}(?!\d)/);
271+
});
272+
273+
it("should handle edge case dates", () => {
274+
// New Year's Day (may show as Dec 31 in local timezone)
275+
const newYear = "2024-01-01T00:00:00Z";
276+
const result1 = formatDateLong(newYear, "en-US");
277+
expect(result1).toMatch(/January.*1.*2024|December.*31.*2023/); // Could be either due to timezone (full month names)
278+
expect(result1).toMatch(/\d{1,2}:\d{2}(?!\d)/);
279+
280+
// Year end
281+
const yearEnd = "2024-12-31T23:59:59Z";
282+
const result2 = formatDateLong(yearEnd, "en-US");
283+
expect(result2).toMatch(/December.*31.*2024/);
284+
expect(result2).toMatch(/\d{1,2}:\d{2}(?!\d)/);
285+
});
286+
287+
it("should handle invalid date strings", () => {
288+
const invalidDate = "invalid-date-string";
289+
const result = formatDateLong(invalidDate, "en-US");
290+
291+
// Should contain "Invalid Date" or similar
292+
expect(result).toMatch(/Invalid Date/i);
293+
});
294+
295+
it("should handle empty date string", () => {
296+
const result = formatDateLong("", "en-US");
297+
expect(result).toMatch(/Invalid Date/i);
298+
});
299+
300+
it("should treat timestamp without timezone as UTC", () => {
301+
const timestampWithoutTZ = "2024-08-29T21:13:10.760877";
302+
const timestampWithTZ = "2024-08-29T21:13:10.760877Z";
303+
304+
const resultWithoutTZ = formatDateLong(timestampWithoutTZ, "en-US");
305+
const resultWithTZ = formatDateLong(timestampWithTZ, "en-US");
306+
307+
// Both should produce the same result since we treat no-TZ as UTC
308+
expect(resultWithoutTZ).toBe(resultWithTZ);
309+
});
195310
});
196311

197312
describe("getInitials", () => {

0 commit comments

Comments
 (0)