feat: 1. Add database migration function. 2. Add download speed statistics persistence and area chart display.

This commit is contained in:
jarvis2f 2025-01-06 18:55:50 +08:00
parent 15c83639a7
commit 2de0e63c9a
33 changed files with 1932 additions and 103 deletions

View file

@ -1,5 +1,6 @@
package telegram.files;
import cn.hutool.core.lang.Version;
import cn.hutool.core.util.StrUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
@ -9,11 +10,12 @@ import io.vertx.core.Promise;
import io.vertx.jdbcclient.JDBCConnectOptions;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.PoolOptions;
import telegram.files.repository.FileRepository;
import telegram.files.repository.SettingRepository;
import telegram.files.repository.TelegramRepository;
import io.vertx.sqlclient.SqlConnection;
import org.jooq.lambda.tuple.Tuple;
import telegram.files.repository.*;
import telegram.files.repository.impl.FileRepositoryImpl;
import telegram.files.repository.impl.SettingRepositoryImpl;
import telegram.files.repository.impl.StatisticRepositoryImpl;
import telegram.files.repository.impl.TelegramRepositoryImpl;
import java.io.File;
@ -31,6 +33,8 @@ public class DataVerticle extends AbstractVerticle {
public static SettingRepository settingRepository;
public static StatisticRepository statisticRepository;
public void start(Promise<Void> stopPromise) {
pool = JDBCPool.pool(vertx,
new JDBCConnectOptions()
@ -38,14 +42,26 @@ public class DataVerticle extends AbstractVerticle {
,
new PoolOptions().setMaxSize(16).setName("pool-tf")
);
settingRepository = new SettingRepositoryImpl(pool);
telegramRepository = new TelegramRepositoryImpl(pool);
fileRepository = new FileRepositoryImpl(pool);
settingRepository = new SettingRepositoryImpl(pool);
Future.all(List.of(
telegramRepository.init(),
fileRepository.init(),
settingRepository.init()
))
statisticRepository = new StatisticRepositoryImpl(pool);
List<Definition> definitions = List.of(
new SettingRecord.SettingRecordDefinition(),
new TelegramRecord.TelegramRecordDefinition(),
new FileRecord.FileRecordDefinition(),
new StatisticRecord.StatisticRecordDefinition()
);
pool.getConnection()
.compose(conn -> Future.all(definitions.stream().map(d -> d.createTable(conn)).toList()).map(conn))
.compose(conn -> settingRepository.<Version>getByKey(SettingKey.version).map(v -> Tuple.tuple(conn, v)))
.compose(tuple -> {
SqlConnection conn = tuple.v1;
Version version = tuple.v2 == null ? new Version("0.0.0") : tuple.v2;
return Future.all(definitions.stream().map(d -> d.migrate(conn, version, new Version(Start.VERSION))).toList());
})
.compose(r ->
settingRepository.createOrUpdate(SettingKey.version.name(), Start.VERSION))
.onSuccess(r -> {
log.info("Database initialized");
stopPromise.complete();

View file

@ -398,12 +398,19 @@ public class HttpVerticle extends AbstractVerticle {
ctx.fail(400);
return;
}
getTelegramVerticle(telegramId)
.ifPresentOrElse(telegramVerticle ->
telegramVerticle.getDownloadStatistics()
.onSuccess(ctx::json)
.onFailure(ctx::fail),
() -> ctx.fail(404));
Optional<TelegramVerticle> telegramVerticleOptional = getTelegramVerticle(telegramId);
if (telegramVerticleOptional.isEmpty()) {
ctx.fail(404);
return;
}
TelegramVerticle telegramVerticle = telegramVerticleOptional.get();
String type = ctx.request().getParam("type");
String timeRange = ctx.request().getParam("timeRange");
(Objects.equals(type, "phase") ? telegramVerticle. getDownloadStatisticsByPhase(Convert.toInt(timeRange, 1)) :
telegramVerticle.getDownloadStatistics())
.onSuccess(ctx::json)
.onFailure(ctx::fail);
}
private void handleTelegramChange(RoutingContext ctx) {

View file

@ -0,0 +1,13 @@
package telegram.files;
import java.time.LocalDateTime;
public class MessyUtils {
public static LocalDateTime withGrouping5Minutes(LocalDateTime time) {
int minute = time.getMinute();
int minuteGroup = minute / 5;
int newMinute = minuteGroup * 5;
return time.withMinute(newMinute).withSecond(0).withNano(0);
}
}

View file

@ -9,7 +9,7 @@ import java.util.concurrent.CountDownLatch;
public class Start {
private static final Log log = LogFactory.get();
public static final String VERSION = "0.1.6";
public static final String VERSION = "0.1.7";
private static final CountDownLatch shutdownLatch = new CountDownLatch(1);

View file

@ -276,7 +276,9 @@ public class TdApiHelp {
Base64.encode((byte[]) BeanUtil.getProperty(content, "photo.minithumbnail.data")),
content.caption.text,
null,
"idle"
"idle",
System.currentTimeMillis(),
null
);
}
}
@ -329,7 +331,9 @@ public class TdApiHelp {
Base64.encode((byte[]) BeanUtil.getProperty(content, "video.minithumbnail.data")),
content.caption.text,
null,
"idle"
"idle",
System.currentTimeMillis(),
null
);
}
}
@ -369,7 +373,9 @@ public class TdApiHelp {
Base64.encode((byte[]) BeanUtil.getProperty(content, "audio.albumCoverMinithumbnail.data")),
content.caption.text,
null,
"idle"
"idle",
System.currentTimeMillis(),
null
);
}
}
@ -409,7 +415,9 @@ public class TdApiHelp {
Base64.encode((byte[]) BeanUtil.getProperty(content, "document.minithumbnail.data")),
content.caption.text,
null,
"idle"
"idle",
System.currentTimeMillis(),
null
);
}
}

View file

@ -4,6 +4,9 @@ package telegram.files;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ArrayUtil;
@ -100,6 +103,7 @@ public class TelegramVerticle extends AbstractVerticle {
telegramUpdateHandler.setOnFileDownloadsUpdated(this::onFileDownloadsUpdated);
telegramUpdateHandler.setOnMessageReceived(this::onMessageReceived);
client = Client.create(telegramUpdateHandler, this::handleException, this::handleException);
vertx.setPeriodic(60 * 1000, id -> handleSaveAvgSpeed());
this.enableProxy(this.proxyName)
.onSuccess(r -> startPromise.complete())
.onFailure(startPromise::fail);
@ -474,6 +478,72 @@ public class TelegramVerticle extends AbstractVerticle {
});
}
public Future<JsonObject> getDownloadStatisticsByPhase(Integer timeRange) {
// 1: 1 hour, 2: 1 day, 3: 1 week, 4: 1 month
long endTime = System.currentTimeMillis();
long startTime = switch (timeRange) {
case 1 -> DateUtil.offsetHour(DateUtil.date(), -1).getTime();
case 2 -> DateUtil.offsetDay(DateUtil.date(), -1).getTime();
case 3 -> DateUtil.offsetWeek(DateUtil.date(), -1).getTime();
case 4 -> DateUtil.offsetMonth(DateUtil.date(), -1).getTime();
default -> throw new IllegalStateException("Unexpected value: " + timeRange);
};
return Future.all(
DataVerticle.statisticRepository.getRangeStatistics(StatisticRecord.Type.speed, this.telegramRecord.id(), startTime, endTime)
.map(statisticRecords -> {
TreeMap<String, List<JsonObject>> groupedSpeedStats = new TreeMap<>(Comparator.comparing(
switch (timeRange) {
case 1, 2 -> (Function<? super String, ? extends DateTime>) time ->
DateUtil.parse(time, DatePattern.NORM_DATETIME_MINUTE_FORMAT);
case 3, 4 -> DateUtil::parseDate;
default -> throw new IllegalStateException("Unexpected value: " + timeRange);
}
));
for (StatisticRecord record : statisticRecords) {
JsonObject data = new JsonObject(record.data());
long timestamp = record.timestamp();
String time = switch (timeRange) {
case 1 ->
MessyUtils.withGrouping5Minutes(DateUtil.toLocalDateTime(DateUtil.date(timestamp))).format(DatePattern.NORM_DATETIME_MINUTE_FORMATTER);
case 2 ->
DateUtil.date(timestamp).setField(DateField.MINUTE, 0).toString(DatePattern.NORM_DATETIME_MINUTE_FORMAT);
case 3, 4 ->
DateUtil.date(timestamp).setField(DateField.MINUTE, 0).toString(DatePattern.NORM_DATE_FORMAT);
default -> throw new IllegalStateException("Unexpected value: " + timeRange);
};
groupedSpeedStats.computeIfAbsent(time, k -> new ArrayList<>()).add(data);
}
return groupedSpeedStats.entrySet().stream()
.map(entry -> {
JsonObject speedStat = entry.getValue().stream().reduce(new JsonObject()
.put("avgSpeed", 0)
.put("medianSpeed", 0)
.put("maxSpeed", 0)
.put("minSpeed", Long.MAX_VALUE),
(a, b) -> new JsonObject()
.put("avgSpeed", a.getLong("avgSpeed") + b.getLong("avgSpeed"))
.put("medianSpeed", a.getLong("medianSpeed") + b.getLong("medianSpeed"))
.put("maxSpeed", Math.max(a.getLong("maxSpeed"), b.getLong("maxSpeed")))
.put("minSpeed", Math.min(a.getLong("minSpeed"), b.getLong("minSpeed")))
);
int size = entry.getValue().size();
speedStat.put("avgSpeed", speedStat.getLong("avgSpeed") / size)
.put("medianSpeed", speedStat.getLong("medianSpeed") / size);
return new JsonObject()
.put("time", entry.getKey())
.put("data", speedStat);
})
.toList();
}),
DataVerticle.fileRepository.getCompletedRangeStatistics(this.telegramRecord.id(), startTime, endTime, timeRange)
)
.map(r -> new JsonObject()
.put("speedStats", r.resultAt(0))
.put("completedStats", r.resultAt(1))
);
}
public Future<TdApi.Proxy> enableProxy(String proxyName) {
if (StrUtil.isBlank(proxyName)) return Future.succeededFuture();
return DataVerticle.settingRepository.<SettingProxyRecords>getByKey(SettingKey.proxys)
@ -625,6 +695,23 @@ public class TelegramVerticle extends AbstractVerticle {
log.error(e);
}
private void handleSaveAvgSpeed() {
if (!authorized || telegramRecord == null) return;
AvgSpeed.SpeedStats speedStats = avgSpeed.getSpeedStats();
if (speedStats.avgSpeed() == 0
&& speedStats.minSpeed() == 0
&& speedStats.medianSpeed() == 0
&& speedStats.maxSpeed() == 0) {
return;
}
JsonObject data = JsonObject.mapFrom(speedStats);
data.remove("interval");
DataVerticle.statisticRepository.create(new StatisticRecord(Convert.toStr(telegramRecord.id()),
StatisticRecord.Type.speed,
System.currentTimeMillis(),
data.encode()));
}
private void onAuthorizationStateUpdated(TdApi.AuthorizationState authorizationState) {
log.debug("[%s] Receive authorization state update: %s".formatted(getRootId(), authorizationState));
this.lastAuthorizationState = authorizationState;
@ -695,20 +782,24 @@ public class TelegramVerticle extends AbstractVerticle {
TdApi.File file = updateFile.file;
if (file != null) {
String localPath = null;
Long completionDate = null;
if (file.local != null && file.local.isDownloadingCompleted) {
localPath = file.local.path;
completionDate = System.currentTimeMillis();
}
FileRecord.DownloadStatus downloadStatus = TdApiHelp.getDownloadStatus(file);
DataVerticle.fileRepository.updateStatus(file.id,
file.remote.uniqueId,
localPath,
downloadStatus)
downloadStatus,
completionDate)
.onSuccess(r -> {
if (r == null || r.isEmpty()) return;
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.put("fileId", file.id)
.put("downloadStatus", r.getString("downloadStatus"))
.put("localPath", r.getString("localPath"))
.put("completionDate", r.getLong("completionDate"))
));
});
}

View file

@ -0,0 +1,47 @@
package telegram.files.repository;
import cn.hutool.core.lang.Version;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.Future;
import io.vertx.sqlclient.SqlConnection;
import java.util.TreeMap;
import java.util.stream.Stream;
public interface Definition {
Log log = LogFactory.get();
String getScheme();
default TreeMap<Version, String[]> getMigrations() {
return new TreeMap<>();
}
default Future<Void> createTable(SqlConnection conn) {
return conn
.query(getScheme())
.execute()
.onFailure(err -> log.error("Failed to create table: %s".formatted(err.getMessage())))
.mapEmpty();
}
default Future<Void> migrate(SqlConnection conn, Version lastVersion, Version currentVersion) {
TreeMap<Version, String[]> migrations = getMigrations();
if (migrations.isEmpty()) {
return Future.succeededFuture();
}
return Future.all(migrations.subMap(lastVersion, false, currentVersion, true).values()
.stream()
.flatMap(arr -> Stream.of(arr)
.map(sql -> conn.query(sql)
.execute()
.onFailure(e -> log.error("Failed to apply migration: %s".formatted(sql), e)))
)
.toList()
)
.onFailure(err -> log.error("Failed to migrate table: %s".formatted(err.getMessage())))
.mapEmpty();
}
}

View file

@ -1,10 +1,14 @@
package telegram.files.repository;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Version;
import cn.hutool.core.map.MapUtil;
import io.vertx.sqlclient.templates.RowMapper;
import io.vertx.sqlclient.templates.TupleMapper;
import java.util.Objects;
import java.util.TreeMap;
public record FileRecord(int id, //file id will change
String uniqueId, // unique id of the file, if empty, it means the file is cant be downloaded
long telegramId,
@ -20,7 +24,9 @@ public record FileRecord(int id, //file id will change
String thumbnail,
String caption,
String localPath,
String downloadStatus // 'idle' | 'downloading' | 'paused' | 'completed' | 'error'
String downloadStatus, // 'idle' | 'downloading' | 'paused' | 'completed' | 'error'
long startDate, // date when the file was started to download
Long completionDate // date when the file was downloaded
) {
public enum DownloadStatus {
@ -46,10 +52,31 @@ public record FileRecord(int id, //file id will change
caption VARCHAR(255),
local_path VARCHAR(255),
download_status VARCHAR(255),
start_date BIGINT,
completion_date BIGINT,
PRIMARY KEY (id, unique_id)
)
""";
public static final TreeMap<Version, String[]> MIGRATIONS = new TreeMap<>(MapUtil.ofEntries(
MapUtil.entry(new Version("0.1.7"), new String[]{
"ALTER TABLE file_record ADD COLUMN start_date BIGINT;",
"ALTER TABLE file_record ADD COLUMN completion_date BIGINT;",
})
));
public static class FileRecordDefinition implements Definition {
@Override
public String getScheme() {
return SCHEME;
}
@Override
public TreeMap<Version, String[]> getMigrations() {
return MIGRATIONS;
}
}
public static RowMapper<FileRecord> ROW_MAPPER = row ->
new FileRecord(row.getInteger("id"),
row.getString("unique_id"),
@ -66,7 +93,9 @@ public record FileRecord(int id, //file id will change
row.getString("thumbnail"),
row.getString("caption"),
row.getString("local_path"),
row.getString("download_status")
row.getString("download_status"),
Objects.requireNonNullElse(row.getLong("start_date"), 0L),
row.getLong("completion_date")
);
public static TupleMapper<FileRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
@ -86,11 +115,13 @@ public record FileRecord(int id, //file id will change
MapUtil.entry("thumbnail", r.thumbnail()),
MapUtil.entry("caption", r.caption()),
MapUtil.entry("local_path", r.localPath()),
MapUtil.entry("download_status", r.downloadStatus())
MapUtil.entry("download_status", r.downloadStatus()),
MapUtil.entry("start_date", r.startDate()),
MapUtil.entry("completion_date", r.completionDate())
));
public FileRecord withSourceField(int id, long downloadedSize) {
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus);
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, startDate, completionDate);
}
}

View file

@ -2,6 +2,7 @@ package telegram.files.repository;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.jooq.lambda.tuple.Tuple3;
@ -9,8 +10,6 @@ import java.util.List;
import java.util.Map;
public interface FileRepository {
Future<Void> init();
Future<FileRecord> create(FileRecord fileRecord);
Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds);
@ -25,15 +24,17 @@ public interface FileRepository {
Future<JsonObject> getDownloadStatistics(long telegramId);
Future<JsonArray> getCompletedRangeStatistics(long id, long startTime, long endTime, int timeRange);
Future<Integer> countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus);
Future<JsonObject> updateStatus(int fileId,
String uniqueId,
String localPath,
FileRecord.DownloadStatus downloadStatus);
FileRecord.DownloadStatus downloadStatus,
Long completionDate);
Future<Void> updateFileId(int fileId, String uniqueId);
Future<Void> deleteByUniqueId(String uniqueId);
}

View file

@ -1,11 +1,13 @@
package telegram.files.repository;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Version;
import io.vertx.core.json.JsonObject;
import java.util.function.Function;
public enum SettingKey {
version(Version::new),
uniqueOnly(Convert::toBool, false),
needToLoadImages(Convert::toBool, false),
imageLoadSize,
@ -17,6 +19,10 @@ public enum SettingKey {
*/
autoDownloadLimit(Convert::toInt),
proxys(value -> new JsonObject(value).mapTo(SettingProxyRecords.class)),
/**
* Interval for calculating average speed, in seconds
*/
avgSpeedInterval(Convert::toInt, 5 * 60),
;
public final Function<String, ?> converter;

View file

@ -15,6 +15,13 @@ public record SettingRecord(String key, String value) {
)
""";
public static class SettingRecordDefinition implements Definition {
@Override
public String getScheme() {
return SCHEME;
}
}
public static RowMapper<SettingRecord> ROW_MAPPER = row ->
new SettingRecord(row.getString("key"),
row.getString("value")

View file

@ -5,9 +5,6 @@ import io.vertx.core.Future;
import java.util.List;
public interface SettingRepository {
Future<Void> init();
Future<SettingRecord> createOrUpdate(String key, String value);
Future<List<SettingRecord>> getByKeys(List<String> keys);

View file

@ -0,0 +1,49 @@
package telegram.files.repository;
import cn.hutool.core.map.MapUtil;
import io.vertx.sqlclient.templates.RowMapper;
import io.vertx.sqlclient.templates.TupleMapper;
public record StatisticRecord(
String relatedId,
Type type,
long timestamp,
String data) {
public enum Type {
speed,
;
}
public static final String SCHEME = """
CREATE TABLE IF NOT EXISTS statistic_record
(
related_id VARCHAR(255),
type VARCHAR(255),
timestamp BIGINT,
data TEXT
)
""";
public static class StatisticRecordDefinition implements Definition {
@Override
public String getScheme() {
return SCHEME;
}
}
public static RowMapper<StatisticRecord> ROW_MAPPER = row ->
new StatisticRecord(row.getString("related_id"),
Type.valueOf(row.getString("type")),
row.getLong("timestamp"),
row.getString("data")
);
public static TupleMapper<StatisticRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
MapUtil.ofEntries(MapUtil.entry("related_id", r.relatedId),
MapUtil.entry("type", r.type().name()),
MapUtil.entry("timestamp", r.timestamp()),
MapUtil.entry("data", r.data())
));
}

View file

@ -0,0 +1,14 @@
package telegram.files.repository;
import io.vertx.core.Future;
import java.util.List;
public interface StatisticRepository {
Future<Void> create(StatisticRecord record);
Future<List<StatisticRecord>> getRangeStatistics(StatisticRecord.Type type,
long relatedId,
long startTime,
long endTime);
}

View file

@ -21,6 +21,13 @@ public record TelegramRecord(
)
""";
public static class TelegramRecordDefinition implements Definition {
@Override
public String getScheme() {
return SCHEME;
}
}
public static RowMapper<TelegramRecord> ROW_MAPPER = row ->
new TelegramRecord(row.getLong("id"),
row.getString("first_name"),

View file

@ -5,9 +5,6 @@ import io.vertx.core.Future;
import java.util.List;
public interface TelegramRepository {
Future<Void> init();
String getRootPath();
Future<TelegramRecord> create(TelegramRecord telegramRecord);

View file

@ -3,17 +3,21 @@ package telegram.files.repository.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.IterUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.templates.SqlTemplate;
import org.jooq.lambda.tuple.Tuple;
import org.jooq.lambda.tuple.Tuple3;
import telegram.files.MessyUtils;
import telegram.files.repository.FileRecord;
import telegram.files.repository.FileRepository;
@ -34,19 +38,6 @@ public class FileRepositoryImpl implements FileRepository {
this.pool = pool;
}
@Override
public Future<Void> init() {
return pool.getConnection()
.compose(conn -> conn
.query(FileRecord.SCHEME)
.execute()
.onComplete(r -> conn.close())
.onFailure(err -> log.error("Failed to create table file_record: %s".formatted(err.getMessage())))
.onSuccess(ps -> log.trace("Successfully created table: file_record"))
)
.mapEmpty();
}
@Override
public Future<FileRecord> create(FileRecord fileRecord) {
return SqlTemplate
@ -54,9 +45,9 @@ public class FileRepositoryImpl implements FileRepository {
INSERT INTO file_record(id, unique_id, telegram_id, chat_id, message_id, date, has_sensitive_content, size, downloaded_size,
type, mime_type,
file_name, thumbnail, caption, local_path,
download_status)
download_status, start_date)
values (#{id}, #{unique_id}, #{telegram_id}, #{chat_id}, #{message_id}, #{date}, #{has_sensitive_content}, #{size}, #{downloaded_size}, #{type},
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status})
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date})
""")
.mapFrom(FileRecord.PARAM_MAPPER)
.execute(fileRecord)
@ -235,6 +226,61 @@ public class FileRepositoryImpl implements FileRepository {
.onFailure(err -> log.error("Failed to get download statistics: %s".formatted(err.getMessage())));
}
@Override
public Future<JsonArray> getCompletedRangeStatistics(long telegramId, long startTime, long endTime, int timeRange) {
return SqlTemplate
.forQuery(pool, """
SELECT strftime(
CASE
WHEN #{timeRange} = 1 THEN '%Y-%m-%d %H:%M'
WHEN #{timeRange} = 2 THEN '%Y-%m-%d %H:00'
WHEN #{timeRange} IN (3, 4) THEN '%Y-%m-%d'
END,
datetime(completion_date / 1000, 'unixepoch')
) AS time,
COUNT(*) AS total
FROM file_record
WHERE telegram_id = #{telegramId}
AND completion_date IS NOT NULL
AND completion_date >= #{startTime}
AND completion_date <= #{endTime}
GROUP BY time
ORDER BY time;
""")
.mapTo(row -> new JsonObject()
.put("time", row.getString("time"))
.put("total", row.getInteger("total"))
)
.execute(Map.of("telegramId", telegramId, "startTime", startTime, "endTime", endTime, "timeRange", timeRange))
.map(IterUtil::toList)
.map(rs -> {
if (CollUtil.isEmpty(rs)) {
return JsonArray.of();
}
if (timeRange == 1) {
// Statistics grouped by five minutes
return rs.stream()
.peek(c -> c.put("time", MessyUtils.withGrouping5Minutes(
DateUtil.toLocalDateTime(DateUtil.date(Convert.toLong(c.getString("time"), 0L)))
).format(DatePattern.NORM_DATETIME_MINUTE_FORMATTER)))
.collect(Collectors.groupingBy(c -> c.getString("time"),
Collectors.summingInt(c -> c.getInteger("total"))
))
.entrySet().stream()
.map(e -> new JsonObject()
.put("time", e.getKey())
.put("total", e.getValue())
)
.collect(JsonArray::new, JsonArray::add, JsonArray::addAll);
} else {
JsonArray jsonArray = new JsonArray();
rs.forEach(jsonArray::add);
return jsonArray;
}
})
.onFailure(err -> log.error("Failed to get completed statistics: %s".formatted(err.getMessage())));
}
@Override
public Future<Integer> countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus) {
return SqlTemplate
@ -248,7 +294,11 @@ public class FileRepositoryImpl implements FileRepository {
}
@Override
public Future<JsonObject> updateStatus(int fileId, String uniqueId, String localPath, FileRecord.DownloadStatus downloadStatus) {
public Future<JsonObject> updateStatus(int fileId,
String uniqueId,
String localPath,
FileRecord.DownloadStatus downloadStatus,
Long completionDate) {
if (StrUtil.isBlank(localPath) && downloadStatus == null) {
return Future.succeededFuture(null);
}
@ -265,13 +315,16 @@ public class FileRepositoryImpl implements FileRepository {
return SqlTemplate
.forUpdate(pool, """
UPDATE file_record SET local_path = #{localPath}, download_status = #{downloadStatus}
UPDATE file_record SET local_path = #{localPath},
download_status = #{downloadStatus},
completion_date = #{completionDate}
WHERE id = #{fileId} AND unique_id = #{uniqueId}
""")
.execute(MapUtil.ofEntries(MapUtil.entry("fileId", fileId),
MapUtil.entry("uniqueId", uniqueId),
MapUtil.entry("localPath", pathUpdated ? localPath : record.localPath()),
MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus())
MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus()),
MapUtil.entry("completionDate", completionDate)
))
.onFailure(err ->
log.error("Failed to update file record: %s".formatted(err.getMessage()))
@ -280,6 +333,7 @@ public class FileRepositoryImpl implements FileRepository {
JsonObject result = JsonObject.of();
if (pathUpdated) {
result.put("localPath", localPath);
result.put("completionDate", completionDate);
}
if (downloadStatusUpdated) {
result.put("downloadStatus", downloadStatus.name());

View file

@ -27,19 +27,6 @@ public class SettingRepositoryImpl implements SettingRepository {
this.pool = pool;
}
@Override
public Future<Void> init() {
return pool.getConnection()
.compose(conn -> conn
.query(SettingRecord.SCHEME)
.execute()
.onComplete(r -> conn.close())
.onFailure(err -> log.error("Failed to create table setting_record: %s".formatted(err.getMessage())))
.onSuccess(ps -> log.trace("Successfully created table: setting_record"))
)
.mapEmpty();
}
@Override
public Future<SettingRecord> createOrUpdate(String key, String value) {
return SqlTemplate

View file

@ -0,0 +1,68 @@
package telegram.files.repository.impl;
import cn.hutool.core.collection.IterUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.Future;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.templates.SqlTemplate;
import telegram.files.repository.StatisticRecord;
import telegram.files.repository.StatisticRepository;
import java.util.List;
import java.util.Map;
public class StatisticRepositoryImpl implements StatisticRepository {
private static final Log log = LogFactory.get();
private final JDBCPool pool;
public StatisticRepositoryImpl(JDBCPool pool) {
this.pool = pool;
}
@Override
public Future<Void> create(StatisticRecord record) {
return SqlTemplate
.forUpdate(pool, """
INSERT INTO statistic_record(related_id, type, timestamp, data)
VALUES (#{related_id}, #{type}, #{timestamp}, #{data})
""")
.mapFrom(StatisticRecord.PARAM_MAPPER)
.execute(record)
.onSuccess(r -> log.trace("Successfully created statistic record: %s".formatted(record.relatedId())))
.onFailure(
err -> log.error("Failed to create statistic record: %s".formatted(err.getMessage()))
)
.mapEmpty();
}
@Override
public Future<List<StatisticRecord>> getRangeStatistics(StatisticRecord.Type type,
long relatedId,
long startTime,
long endTime) {
return SqlTemplate
.forQuery(pool, """
SELECT *
FROM statistic_record
WHERE type = #{type}
AND related_id = #{relatedId}
AND timestamp >= #{startTime}
AND timestamp <= #{endTime}
ORDER BY timestamp
""")
.mapTo(StatisticRecord.ROW_MAPPER)
.execute(Map.of(
"type", type.name(),
"relatedId", relatedId,
"startTime", startTime,
"endTime", endTime
))
.map(IterUtil::toList)
.onFailure(
err -> log.error("Failed to get range statistics: %s".formatted(err.getMessage()))
);
}
}

View file

@ -26,19 +26,6 @@ public class TelegramRepositoryImpl implements TelegramRepository {
this.pool = pool;
}
@Override
public Future<Void> init() {
return pool.getConnection()
.compose(conn -> conn
.query(TelegramRecord.SCHEME)
.execute()
.onComplete(r -> conn.close())
.onFailure(err -> log.error("Failed to create table telegram_record: %s".formatted(err.getMessage())))
.onSuccess(ps -> log.trace("Successfully created table: telegram_record"))
)
.mapEmpty();
}
@Override
public String getRootPath() {
return Config.TELEGRAM_ROOT + File.separator + UUID.randomUUID();

View file

@ -1,6 +1,7 @@
package telegram.files;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Version;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.Vertx;
@ -9,6 +10,7 @@ import io.vertx.junit5.VertxTestContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import telegram.files.repository.FileRecord;
import telegram.files.repository.SettingKey;
import telegram.files.repository.TelegramRecord;
@ExtendWith(VertxExtension.class)
@ -22,8 +24,8 @@ public class DataVerticleTest {
log.debug("DATA_PATH:" + DataVerticle.getDataPath());
}
@AfterAll
static void tearDown() {
@AfterEach
void tearDown() {
String dataPath = DataVerticle.getDataPath();
if (FileUtil.file(dataPath).exists()) {
FileUtil.del(dataPath);
@ -37,6 +39,17 @@ public class DataVerticleTest {
.onComplete(testContext.succeedingThenComplete());
}
@Test
@DisplayName("Check table initialization")
void checkTableInitialization(Vertx vertx, VertxTestContext testContext) {
DataVerticle.settingRepository.<Version>getByKey(SettingKey.version)
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
Assertions.assertNotNull(r);
Assertions.assertEquals(new Version(Start.VERSION), r);
testContext.completeNow();
})));
}
@Test
@DisplayName("Test Create telegram record")
void createTelegramRecordTest(Vertx vertx, VertxTestContext testContext) {
@ -69,7 +82,7 @@ public class DataVerticleTest {
@DisplayName("Test Get file record by primary key")
void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) {
FileRecord fileRecord = new FileRecord(
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status"
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", 0, null
);
DataVerticle.fileRepository.create(fileRecord)
.compose(r -> DataVerticle.fileRepository.getByPrimaryKey(r.id(), r.uniqueId()))
@ -83,15 +96,17 @@ public class DataVerticleTest {
@DisplayName("Test update file status")
void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) {
FileRecord fileRecord = new FileRecord(
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status"
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status", 0, null
);
String updateLocalPath = "local_path";
Long completionDate = 1L;
DataVerticle.fileRepository.create(fileRecord)
.compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading))
.compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate))
.compose(r -> {
testContext.verify(() -> {
Assertions.assertEquals(updateLocalPath, r.getString("localPath"));
Assertions.assertEquals(FileRecord.DownloadStatus.downloading.name(), r.getString("downloadStatus"));
Assertions.assertEquals(completionDate, r.getLong("completionDate"));
});
return DataVerticle.fileRepository.getByPrimaryKey(fileRecord.id(), fileRecord.uniqueId());
})

View file

@ -77,7 +77,7 @@ GET http://{{server}}//file/preview?chatId={{chatId}}&messageId={{messageId}}
Cookie: {{tf}}
### Get download statistics
GET http://{{server}}/telegram/{{telegramId}}/download-statistics
GET http://{{server}}/telegram/{{telegramId}}/download-statistics?type=phase&timeRange=3
Cookie: {{tf}}
### Telegram GetMe

590
web/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telegram-files-web",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "telegram-files-web",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@dnd-kit/core": "^6.2.0",
"@dnd-kit/modifiers": "^8.0.0",
@ -35,12 +35,14 @@
"input-otp": "^1.4.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.462.0",
"motion": "^11.15.0",
"next": "^15.0.1",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-use-websocket": "^4.11.1",
"recharts": "^2.15.0",
"spoiled": "^0.3.2",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.5",
@ -2160,6 +2162,60 @@
}
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
"integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@ -3364,8 +3420,117 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@ -3450,6 +3615,11 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3526,6 +3696,15 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -4210,12 +4389,25 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-equals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.0.tgz",
"integrity": "sha512-3VpaQYf+CDFdRQfgsb+3vY7XaKjM35WCMoQTTE8h4S/eUkHzyJFOOA/gATYgoLejy4FBrEQD/sXe5Auk4cW/AQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -4752,6 +4944,14 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
@ -5313,6 +5513,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -5411,6 +5616,31 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion": {
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz",
"integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==",
"dependencies": {
"framer-motion": "^11.15.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "11.14.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
@ -6045,7 +6275,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -6121,8 +6350,7 @@
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-remove-scroll": {
"version": "2.6.0",
@ -6169,6 +6397,20 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -6190,6 +6432,21 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/react-use-websocket": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.11.1.tgz",
@ -6214,6 +6471,41 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz",
"integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.0",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz",
@ -6982,6 +7274,11 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -7245,6 +7542,27 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -8597,6 +8915,60 @@
"@t3-oss/env-core": "0.10.1"
}
},
"@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
},
"@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"requires": {
"@types/d3-color": "*"
}
},
"@types/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="
},
"@types/d3-scale": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
"integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
"requires": {
"@types/d3-time": "*"
}
},
"@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"requires": {
"@types/d3-path": "*"
}
},
"@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@ -9353,8 +9725,84 @@
"csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"requires": {
"internmap": "1 - 2"
}
},
"d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
},
"d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="
},
"d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"requires": {
"d3-color": "1 - 3"
}
},
"d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
},
"d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"requires": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
}
},
"d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"requires": {
"d3-path": "^3.1.0"
}
},
"d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"requires": {
"d3-array": "2 - 3"
}
},
"d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"requires": {
"d3-time": "1 - 3"
}
},
"d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
},
"damerau-levenshtein": {
"version": "1.0.8",
@ -9409,6 +9857,11 @@
"ms": "^2.1.3"
}
},
"decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -9467,6 +9920,15 @@
"esutils": "^2.0.2"
}
},
"dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"requires": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -9989,12 +10451,22 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"fast-equals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.0.tgz",
"integrity": "sha512-3VpaQYf+CDFdRQfgsb+3vY7XaKjM35WCMoQTTE8h4S/eUkHzyJFOOA/gATYgoLejy4FBrEQD/sXe5Auk4cW/AQ=="
},
"fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -10376,6 +10848,11 @@
"side-channel": "^1.0.4"
}
},
"internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
},
"is-array-buffer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
@ -10758,6 +11235,11 @@
"p-locate": "^5.0.0"
}
},
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -10829,6 +11311,15 @@
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
},
"motion": {
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz",
"integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==",
"requires": {
"framer-motion": "^11.15.0",
"tslib": "^2.4.0"
}
},
"motion-dom": {
"version": "11.14.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
@ -11175,7 +11666,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -11219,8 +11709,7 @@
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"react-remove-scroll": {
"version": "2.6.0",
@ -11243,6 +11732,16 @@
"tslib": "^2.0.0"
}
},
"react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"requires": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
}
},
"react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -11252,6 +11751,17 @@
"tslib": "^2.0.0"
}
},
"react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
},
"react-use-websocket": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.11.1.tgz",
@ -11273,6 +11783,36 @@
"picomatch": "^2.2.1"
}
},
"recharts": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz",
"integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==",
"requires": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.0",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"dependencies": {
"react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
}
}
},
"recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"requires": {
"decimal.js-light": "^2.4.1"
}
},
"reflect.getprototypeof": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz",
@ -11790,6 +12330,11 @@
"thenify": ">= 3.1.0 < 4"
}
},
"tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -11970,6 +12515,27 @@
"@radix-ui/react-dialog": "^1.1.1"
}
},
"victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"requires": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -43,12 +43,14 @@
"input-otp": "^1.4.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.462.0",
"motion": "^11.15.0",
"next": "^15.0.1",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-use-websocket": "^4.11.1",
"recharts": "^2.15.0",
"spoiled": "^0.3.2",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.5",

View file

@ -4,6 +4,8 @@ import { Card, CardContent } from "@/components/ui/card";
import { type TelegramFile } from "@/lib/types";
import {
Calendar,
Clock10,
ClockArrowDown,
Download,
FileAudio2Icon,
FileIcon,
@ -183,7 +185,7 @@ function FileDrawer({
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Date</span>
<span>Received At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.date * 1000), {
@ -201,6 +203,34 @@ function FileDrawer({
{prettyBytes(file.downloadedSize)}
</Badge>
</div>
{(file.downloadStatus !== 'idle' && file.startDate !== 0) && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock10 className="h-4 w-4" />
<span>Download At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.startDate), {
addSuffix: true,
})}
</Badge>
</div>
)}
{file.completionDate && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ClockArrowDown className="h-4 w-4" />
<span>Completion At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.completionDate), {
addSuffix: true,
})}
</Badge>
</div>
)}
</div>
</div>

View file

@ -1,4 +1,4 @@
import { Captions, Clock, Copy, FileCheck } from "lucide-react";
import { Captions, Clock, ClockArrowDown, Copy, FileCheck } from "lucide-react";
import SpoiledWrapper from "@/components/spoiled-wrapper";
import {
Tooltip,
@ -63,9 +63,9 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
</TooltipContent>
</Tooltip>
)}
<div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<TooltipTrigger asChild>
<p className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
@ -81,6 +81,25 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
</div>
</TooltipContent>
</Tooltip>
{file.completionDate && (
<Tooltip>
<TooltipTrigger asChild>
<p className="flex items-center gap-2">
<ClockArrowDown className="h-4 w-4" />
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
{formatDistanceToNow(new Date(file.completionDate), {
addSuffix: true,
})}
</span>
</p>
</TooltipTrigger>
<TooltipContent>
<div className="max-w-80 text-wrap rounded p-2">
{`File downloaded at ${new Date(file.completionDate).toLocaleString()}`}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
</div>

View file

@ -0,0 +1,249 @@
import React, { useState } from "react";
import useSWR from "swr";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { request } from "@/lib/api";
import prettyBytes from "pretty-bytes"; // Type definitions
// Type definitions
type TimeRange = "1" | "2" | "3" | "4";
interface SpeedData {
avgSpeed: number;
medianSpeed: number;
maxSpeed: number;
minSpeed: number;
}
interface SpeedStats {
time: string;
data: SpeedData;
}
interface CompletedStats {
time: string;
total: number;
}
interface ApiResponse {
speedStats: SpeedStats[];
completedStats: CompletedStats[];
}
const formatDate = (dateStr: string, timeRange: TimeRange): string => {
const date = new Date(dateStr);
switch (timeRange) {
case "1": // Last hour
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
case "2": // Last day
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
case "3": // Last week
case "4": // Last month
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
};
interface TelegramStatsProps {
telegramId: string;
}
const timeRangeOptions = [
{ value: "1", label: "1 Hour" },
{ value: "2", label: "24 Hours" },
{ value: "3", label: "1 Week" },
{ value: "4", label: "30 Days" },
];
const axisStyle = {
fontSize: 11,
fill: "#6b7280", // text-gray-500
};
const TelegramStats: React.FC<TelegramStatsProps> = ({ telegramId }) => {
const [timeRange, setTimeRange] = useState<TimeRange>("2");
const { data, error, isLoading } = useSWR<ApiResponse, Error>(
`/telegram/${telegramId}/download-statistics?type=phase&timeRange=${timeRange}`,
request,
);
if (error) {
return <div className="p-4 text-red-500">Failed to load statistics</div>;
}
if (isLoading || !data) {
return <div className="p-4 text-gray-500">Loading statistics...</div>;
}
// Transform speed data for the chart
const speedChartData = data.speedStats.map((stat) => ({
time: formatDate(stat.time, timeRange),
"Average Speed": stat.data.avgSpeed,
"Median Speed": stat.data.medianSpeed,
"Max Speed": stat.data.maxSpeed,
"Min Speed": stat.data.minSpeed,
}));
// Transform completion data for the chart
const completionChartData = data.completedStats.map((stat) => ({
time: formatDate(stat.time, timeRange),
"Completed Downloads": stat.total,
}));
return (
<div className="relative space-y-6">
<div className="absolute -top-14 right-1">
<Select
value={timeRange}
onValueChange={(value: TimeRange) => setTimeRange(value)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{timeRangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card>
<CardHeader>
<CardTitle className="px-1">Download Speed Over Time</CardTitle>
</CardHeader>
<CardContent className="px-1">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={speedChartData}>
<CartesianGrid stroke="#e5e7eb" />
<XAxis
dataKey="time"
tick={axisStyle}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={(value: number) =>
prettyBytes(value, { bits: true })
}
tick={axisStyle}
interval="preserveStartEnd"
/>
<Tooltip
formatter={(value: number) =>
prettyBytes(value, { bits: true })
}
contentStyle={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "none",
borderRadius: "6px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
/>
<Legend wrapperStyle={axisStyle} iconType="circle" />
<Area
type="monotone"
dataKey="Max Speed"
stroke="#06b6d4"
fill="#06b6d4"
fillOpacity={0.6}
/>
<Area
type="monotone"
dataKey="Average Speed"
stroke="#8b5cf6"
fill="#8b5cf6"
fillOpacity={0.8}
/>
<Area
type="monotone"
dataKey="Median Speed"
stroke="#f59e0b"
fill="#f59e0b"
fillOpacity={0.2}
/>
<Area
type="monotone"
dataKey="Min Speed"
stroke="#ec4899"
fill="#ec4899"
fillOpacity={0.7}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="px-1">Completed Downloads Over Time</CardTitle>
</CardHeader>
<CardContent className="px-1">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={completionChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis tick={axisStyle} interval="preserveStartEnd" />
<Tooltip
cursor={{ fill: "rgba(59, 130, 246, 0.1)" }}
contentStyle={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "none",
borderRadius: "6px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
/>
<Legend wrapperStyle={axisStyle} iconType="rect" />
<Bar
dataKey="Completed Downloads"
fill="#299d90"
fillOpacity={0.8}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
);
};
export default TelegramStats;

View file

@ -14,6 +14,9 @@ import { useTelegramAccount } from "@/hooks/use-telegram-account";
import Proxys from "@/components/proxys";
import SettingsForm from "@/components/settings-form";
import About from "@/components/about";
import { ChartColumnIncreasingIcon } from "@/components/ui/chart-column-increasing";
import { LayoutPanelTopIcon } from "@/components/ui/layout-panel-top";
import FilePhaseStatistics from "@/components/file-phase-statistics";
export const SettingsDialog: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
@ -53,9 +56,24 @@ export const SettingsDialog: React.FC = () => {
<SettingsForm />
</TabsContent>
<TabsContent value="statistics" className="h-full overflow-hidden">
<div className="h-full flex flex-col overflow-y-scroll">
<div className="flex h-full flex-col overflow-y-scroll">
{accountId ? (
<FileStatistics telegramId={accountId} />
<Tabs defaultValue="panel">
<TabsList className="bg-none!">
<TabsTrigger value="panel" className="w-10 h-10 scale-75 data-[state=active]:bg-accent">
<LayoutPanelTopIcon />
</TabsTrigger>
<TabsTrigger value="phase" className="w-10 h-10 scale-75 data-[state=active]:bg-accent">
<ChartColumnIncreasingIcon />
</TabsTrigger>
</TabsList>
<TabsContent value="panel">
<FileStatistics telegramId={accountId} />
</TabsContent>
<TabsContent value="phase">
<FilePhaseStatistics telegramId={accountId} />
</TabsContent>
</Tabs>
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-lg text-muted-foreground">

View file

@ -0,0 +1,79 @@
"use client";
import { motion, useAnimation, type Variants } from "motion/react";
const frameVariants: Variants = {
visible: { opacity: 1 },
hidden: { opacity: 1 },
};
const lineVariants: Variants = {
visible: { pathLength: 1, opacity: 1 },
hidden: { pathLength: 0, opacity: 0 },
};
const ChartColumnIncreasingIcon = () => {
const controls = useAnimation();
const handleHoverStart = async () => {
await controls.start((i) => ({
pathLength: 0,
opacity: 0,
transition: { delay: i * 0.1, duration: 0.3 },
}));
await controls.start((i) => ({
pathLength: 1,
opacity: 1,
transition: { delay: i * 0.1, duration: 0.3 },
}));
};
const handleHoverEnd = () => {
void controls.start("visible");
};
return (
<div
className="flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent"
onMouseEnter={handleHoverStart}
onMouseLeave={handleHoverEnd}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
variants={lineVariants}
initial="visible"
animate={controls}
custom={1}
d="M13 17V9"
/>
<motion.path
variants={lineVariants}
initial="visible"
animate={controls}
custom={2}
d="M18 17V5"
/>
<motion.path variants={frameVariants} d="M3 3v16a2 2 0 0 0 2 2h16" />
<motion.path
variants={lineVariants}
initial="visible"
animate={controls}
custom={0}
d="M8 17v-3"
/>
</svg>
</div>
);
};
export { ChartColumnIncreasingIcon };

View file

@ -0,0 +1,365 @@
"use client";
/* eslint-disable */
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR }
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item!.dataKey || item!.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View file

@ -0,0 +1,92 @@
"use client";
import { motion, useAnimation } from "motion/react";
const LayoutPanelTopIcon = () => {
const controls = useAnimation();
return (
<div
className="flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent"
onMouseEnter={() => controls.start("animate")}
onMouseLeave={() => controls.start("normal")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.rect
width="18"
height="7"
x="3"
y="3"
rx="1"
initial="normal"
animate={controls}
variants={{
normal: { opacity: 1, translateY: 0 },
animate: {
opacity: [0, 1],
translateY: [-5, 0],
transition: {
opacity: { duration: 0.5, times: [0.2, 1] },
duration: 0.5,
},
},
}}
/>
<motion.rect
width="7"
height="7"
x="3"
y="14"
rx="1"
initial="normal"
animate={controls}
variants={{
normal: { opacity: 1, translateX: 0 },
animate: {
opacity: [0, 1],
translateX: [-10, 0],
transition: {
opacity: { duration: 0.7, times: [0.5, 1] },
translateX: { delay: 0.3 },
duration: 0.5,
},
},
}}
/>
<motion.rect
width="7"
height="7"
x="14"
y="14"
rx="1"
initial="normal"
animate={controls}
variants={{
normal: { opacity: 1, translateX: 0 },
animate: {
opacity: [0, 1],
translateX: [10, 0],
transition: {
opacity: { duration: 0.8, times: [0.5, 1] },
translateX: { delay: 0.4 },
duration: 0.5,
},
},
}}
/>
</svg>
</div>
);
};
export { LayoutPanelTopIcon };

View file

@ -30,6 +30,7 @@ export function useFiles(accountId: string, chatId: string) {
{
downloadStatus: DownloadStatus;
localPath: string;
completionDate: number;
}
>
>({});
@ -74,6 +75,7 @@ export function useFiles(accountId: string, chatId: string) {
fileId: number;
downloadStatus: DownloadStatus;
localPath: string;
completionDate: number;
};
setLatestFileStatus((prev) => ({
@ -82,6 +84,7 @@ export function useFiles(accountId: string, chatId: string) {
downloadStatus:
data.downloadStatus ?? prev[data.fileId]?.downloadStatus,
localPath: data.localPath ?? prev[data.fileId]?.localPath,
completionDate: data.completionDate ?? prev[data.fileId]?.completionDate,
},
}));
}, [lastJsonMessage]);
@ -96,6 +99,8 @@ export function useFiles(accountId: string, chatId: string) {
downloadStatus:
latestFilesStatus[file.id]?.downloadStatus ?? file.downloadStatus,
localPath: latestFilesStatus[file.id]?.localPath ?? file.localPath,
completionDate:
latestFilesStatus[file.id]?.completionDate ?? file.completionDate,
});
});
});

View file

@ -45,6 +45,8 @@ export type TelegramFile = {
caption: string;
localPath: string;
hasSensitiveContent: boolean;
startDate: number;
completionDate: number;
};
export type TDFile = {