diff --git a/apps/desktop/src/components/editor/QueryHistory.vue b/apps/desktop/src/components/editor/QueryHistory.vue index e9203e8c7..5b149c271 100644 --- a/apps/desktop/src/components/editor/QueryHistory.vue +++ b/apps/desktop/src/components/editor/QueryHistory.vue @@ -2,15 +2,17 @@ import { ref, computed, onMounted } from "vue"; import { useI18n } from "vue-i18n"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; -import { Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue"; +import { CalendarClock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue"; import { RecycleScroller } from "vue-virtual-scroller"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { useHistoryStore } from "@/stores/historyStore"; import { useToast } from "@/composables/useToast"; import { resolveHistoryActivityKind } from "@/lib/historyActivityKind"; import { canRollbackHistoryEntry } from "@/lib/historyAiAnalysis"; +import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRange, type HistoryDateRange } from "@/lib/historyTimeRange"; import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/historyVirtualList"; import type { HistoryEntry } from "@/lib/api"; import { copyToClipboard } from "@/lib/clipboard"; @@ -31,6 +33,11 @@ type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed const searchText = ref(""); const activeFilter = ref("all"); +const dateRange = ref({ startDate: "", endDate: "" }); +const dateRangeDraft = ref({ startDate: "", endDate: "" }); +const dateRangeOpen = ref(false); +const startDateInputRef = ref(null); +const endDateInputRef = ref(null); const selectedEntry = ref(null); const isRollingBack = ref(false); const showDeleteConfirm = ref(false); @@ -38,6 +45,14 @@ const showClearConfirm = ref(false); const deleteTargetId = ref(null); const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"]; +const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value)); +const dateRangeDraftValid = computed(() => historyDateRangeIsValid(dateRangeDraft.value)); +const dateRangeSummary = computed(() => { + if (!hasDateFilter.value) return ""; + const start = dateRange.value.startDate || t("history.dateRange.unboundedStart"); + const end = dateRange.value.endDate || t("history.dateRange.unboundedEnd"); + return `${start} -> ${end}`; +}); const filtered = computed(() => { const q = searchText.value.toLowerCase(); @@ -46,10 +61,12 @@ const filtered = computed(() => { if (activeFilter.value !== "all" && activeFilter.value !== "failed" && activityKind(entry) !== activeFilter.value) { return false; } + if (!historyEntryMatchesDateRange(entry.executed_at, dateRange.value)) return false; if (!q) return true; return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target].filter(Boolean).some((value) => String(value).toLowerCase().includes(q)); }); }); +const emptyMessage = computed(() => (store.entries.length > 0 ? t("history.emptyFilteredRecent") : t("history.empty"))); function activityKind(entry: HistoryEntry) { return resolveHistoryActivityKind(entry); @@ -122,6 +139,46 @@ function filterLabel(filter: HistoryFilter) { return t(`history.filters.${filter}`); } +function openDateRangeFilter() { + dateRangeDraft.value = { ...dateRange.value }; +} + +function setDateRangeOpen(value: boolean) { + if (value) openDateRangeFilter(); + dateRangeOpen.value = value; +} + +function applyDateRangeFilter() { + if (!dateRangeDraftValid.value) return; + dateRange.value = { ...dateRangeDraft.value }; + dateRangeOpen.value = false; +} + +function clearDateRangeFilter() { + dateRange.value = { startDate: "", endDate: "" }; + dateRangeDraft.value = { startDate: "", endDate: "" }; +} + +function cancelDateRangeFilter() { + dateRangeDraft.value = { ...dateRange.value }; + dateRangeOpen.value = false; +} + +function dateFieldLabel(value: string) { + return value ? value.replaceAll("-", "/") : "yyyy/mm/dd"; +} + +function openDatePicker(input: HTMLInputElement | null) { + if (!input) return; + input.focus(); + const pickerInput = input as HTMLInputElement & { showPicker?: () => void }; + if (pickerInput.showPicker) { + pickerInput.showPicker(); + } else { + input.click(); + } +} + function kindLabel(entry: HistoryEntry) { return t(`history.kinds.${activityKind(entry)}`); } @@ -220,6 +277,72 @@ onMounted(() => store.load());
+ + + + + +
+
{{ t("history.dateRange.title") }}
+
+
+ + -> + +
+
+ {{ t("history.dateRange.invalid") }} +
+
+ +
+ + +
+
+
+
+
+
+ +
@@ -253,7 +376,7 @@ onMounted(() => store.load());
- {{ t("history.empty") }} + {{ emptyMessage }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3b483a5bd..6de672b4b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1670,6 +1670,7 @@ export default { title: "History", search: "Search history...", empty: "No history yet", + emptyFilteredRecent: "No matching records in the latest 200 execution history entries", failed: "Failed", success: "Succeeded", restore: "Restore to editor", @@ -1696,6 +1697,17 @@ export default { schema_change: "Schema changes", failed: "Failed", }, + dateRange: { + title: "Time range", + label: "Execution time:", + start: "Start date", + end: "End date", + apply: "Apply", + clear: "Clear", + invalid: "Start date cannot be later than end date", + unboundedStart: "Start", + unboundedEnd: "End", + }, kinds: { query: "Query", data_change: "Data change", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 86a1abb75..d598983b0 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1376,6 +1376,7 @@ export default { title: "Historial", search: "Buscar en historial...", empty: "Sin historial aún", + emptyFilteredRecent: "No hay registros coincidentes en las últimas 200 entradas del historial de ejecución", failed: "Fallida", success: "Exitosa", restore: "Restaurar en el editor", @@ -1401,6 +1402,17 @@ export default { schema_change: "Cambios de esquema", failed: "Fallidas", }, + dateRange: { + title: "Rango de tiempo", + label: "Hora de ejecución:", + start: "Fecha inicial", + end: "Fecha final", + apply: "Aplicar", + clear: "Limpiar", + invalid: "La fecha inicial no puede ser posterior a la fecha final", + unboundedStart: "Inicio", + unboundedEnd: "Fin", + }, kinds: { query: "Consulta", data_change: "Cambio de datos", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 5cfd29a97..848cc03bf 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1496,6 +1496,7 @@ export default { title: "Cronologia", search: "Cerca nella cronologia...", empty: "Ancora nessuna cronologia", + emptyFilteredRecent: "Nessun record corrispondente nelle ultime 200 voci della cronologia di esecuzione", failed: "Non riuscita", success: "Riuscita", restore: "Ripristina nell'editor", @@ -1522,6 +1523,17 @@ export default { schema_change: "Modifiche schema", failed: "Non riuscite", }, + dateRange: { + title: "Intervallo di tempo", + label: "Ora di esecuzione:", + start: "Data iniziale", + end: "Data finale", + apply: "Applica", + clear: "Cancella", + invalid: "La data iniziale non può essere successiva alla data finale", + unboundedStart: "Inizio", + unboundedEnd: "Fine", + }, kinds: { query: "Query", data_change: "Modifica dati", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ed3d2c347..34e45643c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1628,6 +1628,7 @@ export default { title: "履歴", search: "履歴を検索...", empty: "まだ履歴がありません", + emptyFilteredRecent: "最近 200 件の実行履歴に一致する記録はありません", failed: "失敗", success: "成功", restore: "エディタに復元", @@ -1654,6 +1655,17 @@ export default { schema_change: "スキーマ変更", failed: "失敗", }, + dateRange: { + title: "時間範囲", + label: "実行時間:", + start: "開始日", + end: "終了日", + apply: "適用", + clear: "クリア", + invalid: "開始日は終了日より後にできません", + unboundedStart: "開始", + unboundedEnd: "終了", + }, kinds: { query: "クエリ", data_change: "データ変更", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f841e6c78..2521f3176 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1507,6 +1507,7 @@ export default { title: "Histórico", search: "Pesquisar histórico...", empty: "Nenhum histórico ainda", + emptyFilteredRecent: "Nenhum registro correspondente nas 200 entradas mais recentes do histórico de execução", failed: "Falhou", success: "Concluído", restore: "Restaurar no editor", @@ -1533,6 +1534,17 @@ export default { schema_change: "Alterações de schema", failed: "Falhou", }, + dateRange: { + title: "Intervalo de tempo", + label: "Hora de execução:", + start: "Data inicial", + end: "Data final", + apply: "Aplicar", + clear: "Limpar", + invalid: "A data inicial não pode ser posterior à data final", + unboundedStart: "Início", + unboundedEnd: "Fim", + }, kinds: { query: "Consulta", data_change: "Alteração de dados", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d9b7423f9..ff5c5229b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1669,6 +1669,7 @@ export default { title: "历史", search: "搜索历史...", empty: "暂无历史记录", + emptyFilteredRecent: "最近 200 条执行历史中没有匹配记录", failed: "失败", success: "成功", restore: "恢复到编辑器", @@ -1695,6 +1696,17 @@ export default { schema_change: "结构变更", failed: "失败", }, + dateRange: { + title: "时间范围", + label: "执行时间:", + start: "开始日期", + end: "结束日期", + apply: "应用", + clear: "清除", + invalid: "开始日期不能晚于结束日期", + unboundedStart: "开始", + unboundedEnd: "结束", + }, kinds: { query: "查询", data_change: "数据变更", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6937d365b..9b278c810 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1498,6 +1498,7 @@ export default { title: "歷史", search: "搜尋歷史……", empty: "暫無歷史記錄", + emptyFilteredRecent: "最近 200 條執行歷史中沒有相符記錄", failed: "失敗", success: "成功", restore: "復原到編輯器", @@ -1524,6 +1525,17 @@ export default { schema_change: "結構變更", failed: "失敗", }, + dateRange: { + title: "時間範圍", + label: "執行時間:", + start: "開始日期", + end: "結束日期", + apply: "套用", + clear: "清除", + invalid: "開始日期不能晚於結束日期", + unboundedStart: "開始", + unboundedEnd: "結束", + }, kinds: { query: "查詢", data_change: "資料變更", diff --git a/apps/desktop/src/lib/historyTimeRange.ts b/apps/desktop/src/lib/historyTimeRange.ts new file mode 100644 index 000000000..99a4cfa1b --- /dev/null +++ b/apps/desktop/src/lib/historyTimeRange.ts @@ -0,0 +1,51 @@ +export interface HistoryDateRange { + startDate: string; + endDate: string; +} + +export function hasHistoryDateRange(range: HistoryDateRange): boolean { + return !!range.startDate || !!range.endDate; +} + +export function historyEntryMatchesDateRange(executedAt: string, range: HistoryDateRange): boolean { + if (!hasHistoryDateRange(range)) return true; + + const executedTime = new Date(executedAt).getTime(); + if (Number.isNaN(executedTime)) return false; + + const startTime = startOfLocalDate(range.startDate); + const endTime = endOfLocalDate(range.endDate); + if (startTime !== null && executedTime < startTime) return false; + if (endTime !== null && executedTime > endTime) return false; + return true; +} + +export function historyDateRangeIsValid(range: HistoryDateRange): boolean { + const startTime = startOfLocalDate(range.startDate); + const endTime = endOfLocalDate(range.endDate); + return startTime === null || endTime === null || startTime <= endTime; +} + +function startOfLocalDate(value: string): number | null { + const parts = parseDateInput(value); + if (!parts) return null; + return new Date(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0).getTime(); +} + +function endOfLocalDate(value: string): number | null { + const parts = parseDateInput(value); + if (!parts) return null; + return new Date(parts.year, parts.month - 1, parts.day, 23, 59, 59, 999).getTime(); +} + +function parseDateInput(value: string): { year: number; month: number; day: number } | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null; + return { year, month, day }; +} diff --git a/packages/app-tests/historyTimeRange.test.ts b/packages/app-tests/historyTimeRange.test.ts new file mode 100644 index 000000000..d9bb12d3a --- /dev/null +++ b/packages/app-tests/historyTimeRange.test.ts @@ -0,0 +1,25 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { historyDateRangeIsValid, historyEntryMatchesDateRange } from "../../apps/desktop/src/lib/historyTimeRange.ts"; + +test("matches entries on the selected start and end dates inclusively", () => { + const range = { startDate: "2026-06-01", endDate: "2026-06-18" }; + + assert.equal(historyEntryMatchesDateRange("2026-06-01T00:00:00.000", range), true); + assert.equal(historyEntryMatchesDateRange("2026-06-18T23:59:59.999", range), true); + assert.equal(historyEntryMatchesDateRange("2026-05-31T23:59:59.999", range), false); + assert.equal(historyEntryMatchesDateRange("2026-06-19T00:00:00.000", range), false); +}); + +test("supports open-ended date ranges", () => { + assert.equal(historyEntryMatchesDateRange("2026-06-18T12:00:00.000", { startDate: "2026-06-18", endDate: "" }), true); + assert.equal(historyEntryMatchesDateRange("2026-06-17T23:59:59.999", { startDate: "2026-06-18", endDate: "" }), false); + assert.equal(historyEntryMatchesDateRange("2026-06-18T23:59:59.999", { startDate: "", endDate: "2026-06-18" }), true); + assert.equal(historyEntryMatchesDateRange("2026-06-19T00:00:00.000", { startDate: "", endDate: "2026-06-18" }), false); +}); + +test("validates date range ordering", () => { + assert.equal(historyDateRangeIsValid({ startDate: "2026-06-01", endDate: "2026-06-18" }), true); + assert.equal(historyDateRangeIsValid({ startDate: "2026-06-18", endDate: "2026-06-01" }), false); + assert.equal(historyDateRangeIsValid({ startDate: "2026-06-18", endDate: "" }), true); +});