wacrawl-dashboard 0.1.1
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.
- package/README.md +133 -0
- package/apps/api/dist/db.js +29 -0
- package/apps/api/dist/index.js +115 -0
- package/apps/api/dist/lib/displayName.js +28 -0
- package/apps/api/dist/lib/mediaFsPath.js +80 -0
- package/apps/api/dist/lib/query.js +71 -0
- package/apps/api/dist/lib/searchIndex.js +57 -0
- package/apps/api/dist/lib/timezone.js +38 -0
- package/apps/api/dist/lib/wordCloudUsefulStopWords.js +118 -0
- package/apps/api/dist/pathsResolve.js +142 -0
- package/apps/api/dist/routes/data.js +417 -0
- package/apps/api/dist/routes/settings.js +38 -0
- package/apps/api/dist/routes/stats.js +388 -0
- package/apps/api/dist/runtimePaths.js +10 -0
- package/apps/api/dist/scripts/optimizeDb.js +14 -0
- package/apps/api/dist/types.js +1 -0
- package/apps/web/dist/assets/index-1fGu93mF.css +1 -0
- package/apps/web/dist/assets/index-Dfsoi6vG.js +43 -0
- package/apps/web/dist/index.html +13 -0
- package/bin/wacrawl-dashboard.js +41 -0
- package/package.json +56 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getDb } from '../db.js';
|
|
3
|
+
import { cleanDisplayNameSql, contactDisplayNameSql, contactMatchSql } from '../lib/displayName.js';
|
|
4
|
+
import { bucketDateSql, normalizeBucketDate, parseGranularity, parseLimit, parsePeriod, parseYear, sinceTimestamp, unixSecondsToIso, yearBounds, } from '../lib/query.js';
|
|
5
|
+
import { hourInTimezone, resolveStatsTimeZone } from '../lib/timezone.js';
|
|
6
|
+
import { USEFUL_WORD_STOP_SET } from '../lib/wordCloudUsefulStopWords.js';
|
|
7
|
+
export function getOverviewStats(db = getDb()) {
|
|
8
|
+
const messageRow = db.prepare(`
|
|
9
|
+
SELECT
|
|
10
|
+
COUNT(*) AS totalMessages,
|
|
11
|
+
SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS totalMediaFiles,
|
|
12
|
+
MIN(ts) AS oldestTs,
|
|
13
|
+
MAX(ts) AS newestTs
|
|
14
|
+
FROM messages
|
|
15
|
+
`).get();
|
|
16
|
+
const chatRow = db.prepare('SELECT COUNT(*) AS count FROM chats').get();
|
|
17
|
+
const contactRow = db.prepare('SELECT COUNT(*) AS count FROM contacts').get();
|
|
18
|
+
return {
|
|
19
|
+
totalMessages: messageRow.totalMessages,
|
|
20
|
+
totalChats: chatRow.count,
|
|
21
|
+
totalContacts: contactRow.count,
|
|
22
|
+
totalMediaFiles: messageRow.totalMediaFiles ?? 0,
|
|
23
|
+
oldestMessage: unixSecondsToIso(messageRow.oldestTs),
|
|
24
|
+
newestMessage: unixSecondsToIso(messageRow.newestTs),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function getTopContacts(params, db = getDb()) {
|
|
28
|
+
const period = parsePeriod(params.period);
|
|
29
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
30
|
+
const since = sinceTimestamp(period);
|
|
31
|
+
const rows = db.prepare(`
|
|
32
|
+
WITH normalized_messages AS (
|
|
33
|
+
SELECT
|
|
34
|
+
COALESCE(
|
|
35
|
+
contacts.jid,
|
|
36
|
+
CASE
|
|
37
|
+
WHEN from_me = 1 THEN chat_jid
|
|
38
|
+
ELSE COALESCE(sender_jid, chat_jid)
|
|
39
|
+
END
|
|
40
|
+
) AS jid,
|
|
41
|
+
${contactDisplayNameSql('contacts')} AS contact_name,
|
|
42
|
+
contacts.phone AS contact_phone,
|
|
43
|
+
${cleanDisplayNameSql(`
|
|
44
|
+
CASE
|
|
45
|
+
WHEN from_me = 1 THEN messages.chat_name
|
|
46
|
+
ELSE messages.sender_name
|
|
47
|
+
END
|
|
48
|
+
`)} AS message_name,
|
|
49
|
+
${cleanDisplayNameSql(`
|
|
50
|
+
COALESCE(
|
|
51
|
+
chats.name,
|
|
52
|
+
CASE
|
|
53
|
+
WHEN messages.chat_jid = CASE
|
|
54
|
+
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
55
|
+
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
56
|
+
END THEN messages.chat_name
|
|
57
|
+
ELSE NULL
|
|
58
|
+
END
|
|
59
|
+
)
|
|
60
|
+
`)} AS direct_chat_name,
|
|
61
|
+
from_me
|
|
62
|
+
FROM messages
|
|
63
|
+
LEFT JOIN chats ON chats.jid = CASE
|
|
64
|
+
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
65
|
+
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
66
|
+
END
|
|
67
|
+
LEFT JOIN contacts ON ${contactMatchSql('contacts', `CASE
|
|
68
|
+
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
69
|
+
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
70
|
+
END`)}
|
|
71
|
+
WHERE ts >= @since
|
|
72
|
+
)
|
|
73
|
+
SELECT
|
|
74
|
+
jid,
|
|
75
|
+
COALESCE(
|
|
76
|
+
MAX(message_name),
|
|
77
|
+
MAX(direct_chat_name),
|
|
78
|
+
MAX(contact_name),
|
|
79
|
+
${cleanDisplayNameSql('jid')},
|
|
80
|
+
'Unknown'
|
|
81
|
+
) AS name,
|
|
82
|
+
MAX(contact_phone) AS phone,
|
|
83
|
+
COUNT(*) AS messageCount,
|
|
84
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
|
|
85
|
+
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS sentByThem
|
|
86
|
+
FROM normalized_messages
|
|
87
|
+
WHERE jid IS NOT NULL AND jid <> ''
|
|
88
|
+
GROUP BY jid
|
|
89
|
+
ORDER BY messageCount DESC
|
|
90
|
+
LIMIT @limit
|
|
91
|
+
`).all({ since, limit });
|
|
92
|
+
return rows.map((row) => ({
|
|
93
|
+
jid: row.jid ?? 'unknown',
|
|
94
|
+
name: row.name ?? row.jid ?? 'Unknown',
|
|
95
|
+
phone: row.phone?.trim() ? row.phone.trim() : null,
|
|
96
|
+
messageCount: row.messageCount,
|
|
97
|
+
sentByMe: row.sentByMe ?? 0,
|
|
98
|
+
sentByThem: row.sentByThem ?? 0,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
export function getMessageVolume(params, db = getDb()) {
|
|
102
|
+
const period = parsePeriod(params.period);
|
|
103
|
+
const fallbackGranularity = period === 'year' || period === 'all' ? 'month' : 'day';
|
|
104
|
+
const granularity = parseGranularity(params.granularity, fallbackGranularity);
|
|
105
|
+
const since = sinceTimestamp(period);
|
|
106
|
+
const bucketSql = bucketDateSql(granularity);
|
|
107
|
+
const rows = db.prepare(`
|
|
108
|
+
SELECT
|
|
109
|
+
${bucketSql} AS date,
|
|
110
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sent,
|
|
111
|
+
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS received
|
|
112
|
+
FROM messages
|
|
113
|
+
WHERE ts >= @since
|
|
114
|
+
GROUP BY date
|
|
115
|
+
ORDER BY date ASC
|
|
116
|
+
`).all({ since });
|
|
117
|
+
return rows.map((row) => ({
|
|
118
|
+
date: normalizeBucketDate(row.date, granularity),
|
|
119
|
+
sent: row.sent ?? 0,
|
|
120
|
+
received: row.received ?? 0,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
export function getActivityHeatmap(params, db = getDb()) {
|
|
124
|
+
const year = parseYear(params.year);
|
|
125
|
+
const bounds = yearBounds(year);
|
|
126
|
+
return db.prepare(`
|
|
127
|
+
SELECT date(ts, 'unixepoch') AS date, COUNT(*) AS count
|
|
128
|
+
FROM messages
|
|
129
|
+
WHERE ts >= @start AND ts < @end
|
|
130
|
+
GROUP BY date
|
|
131
|
+
ORDER BY date ASC
|
|
132
|
+
`).all(bounds);
|
|
133
|
+
}
|
|
134
|
+
export function getHourOfDayStats(params, db = getDb()) {
|
|
135
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
136
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
137
|
+
const rows = db.prepare(`
|
|
138
|
+
SELECT ts
|
|
139
|
+
FROM messages
|
|
140
|
+
WHERE ts >= @since
|
|
141
|
+
`).all({ since });
|
|
142
|
+
const counts = new Array(24).fill(0);
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
counts[hourInTimezone(row.ts, timeZone)] += 1;
|
|
145
|
+
}
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let hour = 0; hour < 24; hour += 1) {
|
|
148
|
+
const count = counts[hour] ?? 0;
|
|
149
|
+
if (count > 0)
|
|
150
|
+
out.push({ hour, count });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
export function getDayOfWeekStats(params, db = getDb()) {
|
|
155
|
+
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
156
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
157
|
+
const rows = db.prepare(`
|
|
158
|
+
SELECT strftime('%w', ts, 'unixepoch') AS day, COUNT(*) AS count
|
|
159
|
+
FROM messages
|
|
160
|
+
WHERE ts >= @since
|
|
161
|
+
GROUP BY day
|
|
162
|
+
ORDER BY day ASC
|
|
163
|
+
`).all({ since });
|
|
164
|
+
return rows.map((row) => {
|
|
165
|
+
const day = Number(row.day);
|
|
166
|
+
return { day, label: labels[day] ?? String(day), count: row.count };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export function getMediaBreakdown(params, db = getDb()) {
|
|
170
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
171
|
+
const rows = db.prepare(`
|
|
172
|
+
SELECT media_type AS mediaType, COUNT(*) AS count, COALESCE(SUM(media_size), 0) AS totalBytes
|
|
173
|
+
FROM messages
|
|
174
|
+
WHERE ts >= @since AND media_type IS NOT NULL AND media_type <> ''
|
|
175
|
+
GROUP BY media_type
|
|
176
|
+
ORDER BY count DESC, mediaType ASC
|
|
177
|
+
`).all({ since });
|
|
178
|
+
return rows.map((row) => ({
|
|
179
|
+
mediaType: row.mediaType,
|
|
180
|
+
count: row.count,
|
|
181
|
+
totalBytes: row.totalBytes ?? 0,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
export function getMediaSenders(params, db = getDb()) {
|
|
185
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
186
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
187
|
+
const rows = db.prepare(`
|
|
188
|
+
WITH normalized AS (
|
|
189
|
+
SELECT
|
|
190
|
+
CASE WHEN from_me = 1 THEN chat_jid ELSE COALESCE(sender_jid, chat_jid) END AS jid,
|
|
191
|
+
${cleanDisplayNameSql('CASE WHEN from_me = 1 THEN COALESCE(chats.name, chat_name) ELSE sender_name END')} AS message_name,
|
|
192
|
+
${contactDisplayNameSql('contacts')} AS contact_name,
|
|
193
|
+
media_size
|
|
194
|
+
FROM messages
|
|
195
|
+
LEFT JOIN chats ON chats.jid = CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END
|
|
196
|
+
LEFT JOIN contacts ON ${contactMatchSql('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
197
|
+
WHERE ts >= @since AND media_type IS NOT NULL AND media_type <> ''
|
|
198
|
+
)
|
|
199
|
+
SELECT
|
|
200
|
+
jid,
|
|
201
|
+
COALESCE(MAX(message_name), MAX(contact_name), ${cleanDisplayNameSql('jid')}, 'Unknown') AS name,
|
|
202
|
+
COUNT(*) AS mediaCount,
|
|
203
|
+
COALESCE(SUM(media_size), 0) AS totalBytes
|
|
204
|
+
FROM normalized
|
|
205
|
+
WHERE jid IS NOT NULL AND jid <> ''
|
|
206
|
+
GROUP BY jid
|
|
207
|
+
ORDER BY mediaCount DESC, name COLLATE NOCASE ASC
|
|
208
|
+
LIMIT @limit
|
|
209
|
+
`).all({ since, limit });
|
|
210
|
+
return rows.map((row) => ({
|
|
211
|
+
jid: row.jid ?? 'unknown',
|
|
212
|
+
name: row.name ?? row.jid ?? 'Unknown',
|
|
213
|
+
mediaCount: row.mediaCount,
|
|
214
|
+
totalBytes: row.totalBytes ?? 0,
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
export function getSentReceivedRatio(params, db = getDb()) {
|
|
218
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
219
|
+
const rows = db.prepare(`
|
|
220
|
+
SELECT
|
|
221
|
+
strftime('%Y-%m-01', ts, 'unixepoch') AS month,
|
|
222
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sent,
|
|
223
|
+
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS received
|
|
224
|
+
FROM messages
|
|
225
|
+
WHERE ts >= @since
|
|
226
|
+
GROUP BY month
|
|
227
|
+
ORDER BY month ASC
|
|
228
|
+
`).all({ since });
|
|
229
|
+
return rows.map((row) => ({
|
|
230
|
+
month: row.month,
|
|
231
|
+
sent: row.sent ?? 0,
|
|
232
|
+
received: row.received ?? 0,
|
|
233
|
+
ratio: row.received ? Number((row.sent / row.received).toFixed(2)) : null,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
export function getResponseTimes(params, db = getDb()) {
|
|
237
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
238
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
239
|
+
const rows = db.prepare(`
|
|
240
|
+
WITH ordered AS (
|
|
241
|
+
SELECT
|
|
242
|
+
chat_jid,
|
|
243
|
+
chat_name,
|
|
244
|
+
ts,
|
|
245
|
+
from_me,
|
|
246
|
+
LAG(ts) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS previous_ts,
|
|
247
|
+
LAG(from_me) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS previous_from_me
|
|
248
|
+
FROM messages
|
|
249
|
+
WHERE ts >= @since
|
|
250
|
+
)
|
|
251
|
+
SELECT
|
|
252
|
+
chat_jid AS jid,
|
|
253
|
+
COALESCE(${cleanDisplayNameSql('MAX(chats.name)')}, ${cleanDisplayNameSql('MAX(chat_name)')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('chat_jid')}, 'Unknown') AS name,
|
|
254
|
+
COUNT(*) AS responseCount,
|
|
255
|
+
ROUND(AVG(ts - previous_ts)) AS averageSeconds
|
|
256
|
+
FROM ordered
|
|
257
|
+
LEFT JOIN chats ON chats.jid = ordered.chat_jid
|
|
258
|
+
LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'ordered.chat_jid')}
|
|
259
|
+
WHERE previous_ts IS NOT NULL
|
|
260
|
+
AND previous_from_me IS NOT NULL
|
|
261
|
+
AND previous_from_me <> from_me
|
|
262
|
+
AND ts > previous_ts
|
|
263
|
+
GROUP BY chat_jid
|
|
264
|
+
ORDER BY averageSeconds ASC
|
|
265
|
+
LIMIT @limit
|
|
266
|
+
`).all({ since, limit });
|
|
267
|
+
return rows.map((row) => ({
|
|
268
|
+
jid: row.jid,
|
|
269
|
+
name: row.name ?? row.jid,
|
|
270
|
+
responseCount: row.responseCount,
|
|
271
|
+
averageSeconds: row.averageSeconds ?? 0,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
export function getGroupActivity(params, db = getDb()) {
|
|
275
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
276
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
277
|
+
const rows = db.prepare(`
|
|
278
|
+
SELECT
|
|
279
|
+
chats.jid,
|
|
280
|
+
COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('MAX(messages.chat_name)')}, ${cleanDisplayNameSql('chats.jid')}, 'Unknown') AS name,
|
|
281
|
+
COUNT(messages.rowid) AS messageCount,
|
|
282
|
+
COUNT(DISTINCT CASE WHEN messages.from_me = 0 THEN messages.sender_jid END) AS participantCount
|
|
283
|
+
FROM chats
|
|
284
|
+
JOIN messages ON messages.chat_jid = chats.jid
|
|
285
|
+
WHERE chats.kind = 'group' AND messages.ts >= @since
|
|
286
|
+
GROUP BY chats.jid
|
|
287
|
+
ORDER BY messageCount DESC, name COLLATE NOCASE ASC
|
|
288
|
+
LIMIT @limit
|
|
289
|
+
`).all({ since, limit });
|
|
290
|
+
return rows.map((row) => ({
|
|
291
|
+
jid: row.jid,
|
|
292
|
+
name: row.name ?? row.jid,
|
|
293
|
+
messageCount: row.messageCount,
|
|
294
|
+
participantCount: row.participantCount,
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
export function getMessageStreaks(params, db = getDb()) {
|
|
298
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
299
|
+
const rows = db.prepare(`
|
|
300
|
+
SELECT DISTINCT date(ts, 'unixepoch') AS date
|
|
301
|
+
FROM messages
|
|
302
|
+
WHERE ts >= @since
|
|
303
|
+
ORDER BY date ASC
|
|
304
|
+
`).all({ since });
|
|
305
|
+
let longestStreak = 0;
|
|
306
|
+
let activeRun = 0;
|
|
307
|
+
let previousTime = null;
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
const time = Date.parse(`${row.date}T00:00:00.000Z`);
|
|
310
|
+
activeRun = previousTime != null && time - previousTime === 86_400_000 ? activeRun + 1 : 1;
|
|
311
|
+
longestStreak = Math.max(longestStreak, activeRun);
|
|
312
|
+
previousTime = time;
|
|
313
|
+
}
|
|
314
|
+
const today = new Date();
|
|
315
|
+
const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
|
316
|
+
const lastTime = rows.length ? Date.parse(`${rows[rows.length - 1].date}T00:00:00.000Z`) : null;
|
|
317
|
+
const currentStreak = lastTime != null && todayUtc - lastTime <= 86_400_000 ? activeRun : 0;
|
|
318
|
+
return { currentStreak, longestStreak };
|
|
319
|
+
}
|
|
320
|
+
function parseWordCloudFilter(value) {
|
|
321
|
+
return value === 'useful' ? 'useful' : 'all';
|
|
322
|
+
}
|
|
323
|
+
export function getWordCloud(params, db = getDb()) {
|
|
324
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
325
|
+
const limit = parseLimit(params.limit, 50, 200);
|
|
326
|
+
const filterMode = parseWordCloudFilter(params.filter);
|
|
327
|
+
const rows = db.prepare(`
|
|
328
|
+
SELECT text
|
|
329
|
+
FROM messages
|
|
330
|
+
WHERE ts >= @since AND text IS NOT NULL AND TRIM(text) <> ''
|
|
331
|
+
`).all({ since });
|
|
332
|
+
const counts = new Map();
|
|
333
|
+
for (const row of rows) {
|
|
334
|
+
for (const term of (row.text ?? '').toLowerCase().match(/[a-z0-9]{3,}/g) ?? []) {
|
|
335
|
+
if (filterMode === 'useful') {
|
|
336
|
+
if (/^\d+$/.test(term))
|
|
337
|
+
continue;
|
|
338
|
+
if (USEFUL_WORD_STOP_SET.has(term))
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return [...counts.entries()]
|
|
345
|
+
.map(([text, value]) => ({ text, value }))
|
|
346
|
+
.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text))
|
|
347
|
+
.slice(0, limit);
|
|
348
|
+
}
|
|
349
|
+
export const statsRouter = Router();
|
|
350
|
+
statsRouter.get('/overview', (_req, res) => {
|
|
351
|
+
res.json(getOverviewStats());
|
|
352
|
+
});
|
|
353
|
+
statsRouter.get('/top-contacts', (req, res) => {
|
|
354
|
+
res.json(getTopContacts(req.query));
|
|
355
|
+
});
|
|
356
|
+
statsRouter.get('/message-volume', (req, res) => {
|
|
357
|
+
res.json(getMessageVolume(req.query));
|
|
358
|
+
});
|
|
359
|
+
statsRouter.get('/activity-heatmap', (req, res) => {
|
|
360
|
+
res.json(getActivityHeatmap(req.query));
|
|
361
|
+
});
|
|
362
|
+
statsRouter.get('/hour-of-day', (req, res) => {
|
|
363
|
+
res.json(getHourOfDayStats(req.query));
|
|
364
|
+
});
|
|
365
|
+
statsRouter.get('/day-of-week', (req, res) => {
|
|
366
|
+
res.json(getDayOfWeekStats(req.query));
|
|
367
|
+
});
|
|
368
|
+
statsRouter.get('/media-breakdown', (req, res) => {
|
|
369
|
+
res.json(getMediaBreakdown(req.query));
|
|
370
|
+
});
|
|
371
|
+
statsRouter.get('/media-senders', (req, res) => {
|
|
372
|
+
res.json(getMediaSenders(req.query));
|
|
373
|
+
});
|
|
374
|
+
statsRouter.get('/sent-received-ratio', (req, res) => {
|
|
375
|
+
res.json(getSentReceivedRatio(req.query));
|
|
376
|
+
});
|
|
377
|
+
statsRouter.get('/response-times', (req, res) => {
|
|
378
|
+
res.json(getResponseTimes(req.query));
|
|
379
|
+
});
|
|
380
|
+
statsRouter.get('/group-activity', (req, res) => {
|
|
381
|
+
res.json(getGroupActivity(req.query));
|
|
382
|
+
});
|
|
383
|
+
statsRouter.get('/streaks', (req, res) => {
|
|
384
|
+
res.json(getMessageStreaks(req.query));
|
|
385
|
+
});
|
|
386
|
+
statsRouter.get('/word-cloud', (req, res) => {
|
|
387
|
+
res.json(getWordCloud(req.query));
|
|
388
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { resolvePathsForRequest } from './pathsResolve.js';
|
|
3
|
+
export const requestPathsStore = new AsyncLocalStorage();
|
|
4
|
+
export function getResolvedPaths() {
|
|
5
|
+
return requestPathsStore.getStore() ?? resolvePathsForRequest({});
|
|
6
|
+
}
|
|
7
|
+
export function pathsMiddleware(req, _res, next) {
|
|
8
|
+
const merged = resolvePathsForRequest(req.headers);
|
|
9
|
+
requestPathsStore.run(merged, next);
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { getDbPath } from '../db.js';
|
|
3
|
+
import { createSearchIndexes } from '../lib/searchIndex.js';
|
|
4
|
+
const dbPath = getDbPath();
|
|
5
|
+
const db = new Database(dbPath, { fileMustExist: true });
|
|
6
|
+
try {
|
|
7
|
+
const startedAt = Date.now();
|
|
8
|
+
createSearchIndexes(db);
|
|
9
|
+
const elapsedMs = Date.now() - startedAt;
|
|
10
|
+
console.log(`Optimized search indexes for ${dbPath} in ${elapsedMs}ms.`);
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
db.close();
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.left-0{left:0}.left-4{left:1rem}.right-5{right:1.25rem}.right-6{right:1.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-5{top:1.25rem}.top-6{top:1.5rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2 / span 2}.mb-1{margin-bottom:.25rem}.mb-4{margin-bottom:1rem}.ml-\[220px\]{margin-left:220px}.ml-auto{margin-left:auto}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-16{height:4rem}.h-24{height:6rem}.h-3{height:.75rem}.h-32{height:8rem}.h-4{height:1rem}.h-40{height:10rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-56{height:14rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-56{max-height:14rem}.max-h-\[75vh\]{max-height:75vh}.max-h-\[calc\(100vh-180px\)\]{max-height:calc(100vh - 180px)}.max-h-full{max-height:100%}.min-h-72{min-height:18rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[220px\]{width:220px}.w-\[640px\]{width:640px}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-5xl{max-width:64rem}.max-w-\[75\%\]{max-width:75%}.max-w-\[90\%\]{max-width:90%}.max-w-\[calc\(100vw-4rem\)\]{max-width:calc(100vw - 4rem)}.max-w-full{max-width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-full{--tw-translate-y: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.grid-flow-col{grid-auto-flow:column}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[360px_1fr\]{grid-template-columns:360px 1fr}.grid-rows-7{grid-template-rows:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-slate-100>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(241 245 249 / var(--tw-divide-opacity, 1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-\[2rem\]{border-radius:2rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity: 1;border-color:rgb(253 230 138 / var(--tw-border-opacity, 1))}.border-emerald-200{--tw-border-opacity: 1;border-color:rgb(167 243 208 / var(--tw-border-opacity, 1))}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity, 1))}.border-slate-100{--tw-border-opacity: 1;border-color:rgb(241 245 249 / var(--tw-border-opacity, 1))}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-white\/10{border-color:#ffffff1a}.border-white\/15{border-color:#ffffff26}.border-white\/40{border-color:#fff6}.bg-amber-100{--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-brand-50{--tw-bg-opacity: 1;background-color:rgb(236 253 245 / var(--tw-bg-opacity, 1))}.bg-brand-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-brand-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-emerald-200{--tw-bg-opacity: 1;background-color:rgb(167 243 208 / var(--tw-bg-opacity, 1))}.bg-emerald-300{--tw-bg-opacity: 1;background-color:rgb(110 231 183 / var(--tw-bg-opacity, 1))}.bg-emerald-50{--tw-bg-opacity: 1;background-color:rgb(236 253 245 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-700{--tw-bg-opacity: 1;background-color:rgb(4 120 87 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-100\/90{background-color:#f1f5f9e6}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-50{--tw-bg-opacity: 1;background-color:rgb(248 250 252 / var(--tw-bg-opacity, 1))}.bg-slate-50\/90{background-color:#f8fafce6}.bg-slate-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-slate-950{--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.bg-slate-950\/70{background-color:#020617b3}.bg-slate-950\/75{background-color:#020617bf}.bg-slate-950\/80{background-color:#020617cc}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/10{background-color:#ffffff1a}.bg-white\/60{background-color:#fff9}.bg-white\/90{background-color:#ffffffe6}.bg-\[radial-gradient\(circle_at_top_left\,_rgba\(34\,197\,94\,0\.45\)\,_transparent_34\%\)\,radial-gradient\(circle_at_bottom_right\,_rgba\(14\,165\,233\,0\.35\)\,_transparent_30\%\)\]{background-image:radial-gradient(circle at top left,rgba(34,197,94,.45),transparent 34%),radial-gradient(circle at bottom right,rgba(14,165,233,.35),transparent 30%)}.fill-current{fill:currentColor}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-4{padding-bottom:1rem}.pl-12{padding-left:3rem}.pr-4{padding-right:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-6{line-height:1.5rem}.leading-8{line-height:2rem}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-amber-800{--tw-text-opacity: 1;color:rgb(146 64 14 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-brand-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-brand-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-red-900{--tw-text-opacity: 1;color:rgb(127 29 29 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-slate-800{--tw-text-opacity: 1;color:rgb(30 41 59 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-slate-950{--tw-text-opacity: 1;color:rgb(2 6 23 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/50{color:#ffffff80}.text-white\/55{color:#ffffff8c}.text-white\/60{color:#fff9}.text-white\/70{color:#ffffffb3}.text-white\/75{color:#ffffffbf}.text-white\/80{color:#fffc}.underline{text-decoration-line:underline}.decoration-brand-600\/40{text-decoration-color:#16a34a66}.underline-offset-2{text-underline-offset:2px}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-brand-500{--tw-ring-opacity: 1;--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1))}.ring-slate-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(226 232 240 / var(--tw-ring-opacity, 1))}.ring-slate-200\/80{--tw-ring-color: rgb(226 232 240 / .8)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur: blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.\[content-visibility\:auto\]{content-visibility:auto}:root{color-scheme:light;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#f8fafc}body{margin:0;min-width:1024px}*{box-sizing:border-box}.hover\:-translate-y-0\.5:hover{--tw-translate-y: -.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-brand-500:hover{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.hover\:border-slate-300:hover{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.hover\:bg-brand-500:hover{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-100:hover{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-200:hover{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-200\/80:hover{background-color:#e2e8f0cc}.hover\:bg-slate-50:hover{--tw-bg-opacity: 1;background-color:rgb(248 250 252 / var(--tw-bg-opacity, 1))}.hover\:bg-white:hover{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.hover\:bg-white\/10:hover{background-color:#ffffff1a}.hover\:bg-white\/20:hover{background-color:#fff3}.hover\:text-slate-700:hover{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.hover\:text-slate-950:hover{--tw-text-opacity: 1;color:rgb(2 6 23 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:border-brand-500:focus{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.focus\:bg-white:focus{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-4:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-brand-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1))}.focus\:ring-brand-500\/10:focus{--tw-ring-color: rgb(34 197 94 / .1)}.focus\:ring-brand-500\/15:focus{--tw-ring-color: rgb(34 197 94 / .15)}.focus\:ring-brand-500\/40:focus{--tw-ring-color: rgb(34 197 94 / .4)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-4:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-60:disabled{opacity:.6}.dark\:divide-slate-800:is(.dark *)>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(30 41 59 / var(--tw-divide-opacity, 1))}.dark\:border-amber-900:is(.dark *){--tw-border-opacity: 1;border-color:rgb(120 53 15 / var(--tw-border-opacity, 1))}.dark\:border-emerald-900:is(.dark *){--tw-border-opacity: 1;border-color:rgb(6 78 59 / var(--tw-border-opacity, 1))}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-amber-900\/60:is(.dark *){background-color:#78350f99}.dark\:bg-amber-950\/40:is(.dark *){background-color:#451a0366}.dark\:bg-brand-500:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.dark\:bg-brand-600\/20:is(.dark *){background-color:#16a34a33}.dark\:bg-emerald-950\/40:is(.dark *){background-color:#022c2266}.dark\:bg-slate-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-800\/80:is(.dark *){background-color:#1e293bcc}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950\/30:is(.dark *){background-color:#0206174d}.dark\:bg-slate-950\/40:is(.dark *){background-color:#02061766}.dark\:bg-slate-950\/90:is(.dark *){background-color:#020617e6}.dark\:text-amber-200:is(.dark *){--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-amber-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-brand-50:is(.dark *){--tw-text-opacity: 1;color:rgb(236 253 245 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-200:is(.dark *){--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-slate-50:is(.dark *){--tw-text-opacity: 1;color:rgb(248 250 252 / var(--tw-text-opacity, 1))}.dark\:text-slate-500:is(.dark *){--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.dark\:ring-slate-700:is(.dark *){--tw-ring-opacity: 1;--tw-ring-color: rgb(51 65 85 / var(--tw-ring-opacity, 1))}.dark\:hover\:border-brand-600:hover:is(.dark *){--tw-border-opacity: 1;border-color:rgb(22 163 74 / var(--tw-border-opacity, 1))}.dark\:hover\:border-slate-600:hover:is(.dark *){--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.dark\:hover\:bg-brand-600\/30:hover:is(.dark *){background-color:#16a34a4d}.dark\:hover\:bg-slate-700:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.dark\:hover\:bg-slate-700\/80:hover:is(.dark *){background-color:#334155cc}.dark\:hover\:bg-slate-800:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:hover\:text-slate-200:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.dark\:hover\:text-white:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:focus\:bg-slate-900:focus:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}@media(min-width:640px){.sm\:max-h-72{max-height:18rem}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-\[140px_1fr\]{grid-template-columns:140px 1fr}.sm\:p-10{padding:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}}@media(min-width:1024px){.lg\:grid-cols-\[1\.05fr_0\.95fr\]{grid-template-columns:1.05fr .95fr}}
|