mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(history): group IP records with a daily view
Collapse repeated daily IP records into summaries ordered by last seen. Keep country and IP-type filters across both views and expand dates only for IPs recorded on multiple days. Preserve the daily storage format. Co-Authored-By: GPT-6 <noreply@openai.com>
This commit is contained in:
1 parent
ab42ba6acf
commit
5d764cff29
9 files changed
+261
-21
No files matched your search
@@ -35,11 +35,26 @@
|
||||
{{ t('ipHistory.Empty') }}
|
||||
</div>
|
||||
|
||||
<!-- View selection keeps the country/type filters intact. -->
|
||||
<ToggleGroup v-if="hasHistory" :model-value="viewMode" type="single" variant="outline"
|
||||
class="w-full" :aria-label="t('ipHistory.ViewMode')"
|
||||
@update:model-value="(value) => { if (value) viewMode = value; }">
|
||||
<ToggleGroupItem value="ip" class="flex-1 cursor-pointer">
|
||||
{{ t('ipHistory.ByIP') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="date" class="flex-1 cursor-pointer">
|
||||
{{ t('ipHistory.ByDate') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<!-- Filters — flat toggle tags: highlight any combination.
|
||||
Within a row selections OR together, the two rows AND.
|
||||
Nothing highlighted = no filter. Tags and counts come from
|
||||
the full history so the layout never shifts mid-combo. -->
|
||||
<div v-if="hasHistory && (showTypeFilter || showCountryFilter)" class="space-y-2">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t(viewMode === 'ip' ? 'ipHistory.UniqueIPCounts' : 'ipHistory.DailyRecordCounts') }}
|
||||
</p>
|
||||
<!-- IP type tags -->
|
||||
<ToggleGroup v-if="showTypeFilter" v-model="versionFilter" type="multiple" variant="outline"
|
||||
:spacing="2" class="w-full flex-wrap justify-start"
|
||||
@@ -69,15 +84,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty result for the selected tag combination -->
|
||||
<div v-if="hasHistory && displayDays.length === 0"
|
||||
<div v-if="hasHistory && displayGroups.length === 0"
|
||||
class="flex flex-col items-center gap-2 py-10 text-center text-sm text-muted-foreground">
|
||||
<ListFilter class="size-8 opacity-40" />
|
||||
{{ t('ipHistory.NoMatch') }}
|
||||
</div>
|
||||
|
||||
<!-- Day groups, newest first -->
|
||||
<section v-for="group in displayDays" :key="group.day">
|
||||
<h3 class="text-xs uppercase tracking-wide text-muted-foreground mb-2">
|
||||
<!-- IP summaries sort by last seen; date groups sort newest first. -->
|
||||
<section v-for="group in displayGroups" :key="`${viewMode}:${group.day || 'ips'}`">
|
||||
<h3 v-if="group.day" class="text-xs uppercase tracking-wide text-muted-foreground mb-2">
|
||||
{{ formatDay(group.day) }}
|
||||
</h3>
|
||||
<ul class="rounded-lg border bg-card divide-y">
|
||||
@@ -98,6 +113,32 @@
|
||||
{{ entry.asn }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- Single-day records need no expandable date list. -->
|
||||
<div v-if="viewMode === 'ip'" class="mt-3 space-y-2">
|
||||
<p class="text-xs font-medium">
|
||||
{{ t('ipHistory.LastSeen', { date: formatDay(entry.lastSeen) }) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('ipHistory.FirstSeen', { date: formatDay(entry.firstSeen) }) }}
|
||||
</p>
|
||||
<Collapsible v-if="entry.dayCount > 1">
|
||||
<CollapsibleTrigger>
|
||||
<Button type="button" variant="ghost" size="sm"
|
||||
class="group h-auto max-w-full justify-start whitespace-normal px-0 py-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<ChevronDown class="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
|
||||
{{ t('ipHistory.RecordDates') }}
|
||||
<span class="sr-only">{{ entry.ip }}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul class="grid grid-cols-2 gap-x-3 gap-y-2 pt-2 pb-1 text-xs text-muted-foreground">
|
||||
<li v-for="day in entry.dates" :key="day">
|
||||
<time :datetime="day">{{ formatDay(day) }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -119,25 +160,26 @@
|
||||
<script setup>
|
||||
// IPHistory — local record of every IP detected while using the app.
|
||||
// Data comes from store.allIPs via use-ip-history (localStorage, grouped by
|
||||
// day, 90-day retention, never synced to the account). The panel mirrors the
|
||||
// Preferences left-sheet shell.
|
||||
// day, up to 90 days, never synced to the account). IP summaries are derived
|
||||
// from the matching daily records; the date view keeps those records accessible.
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { useIpHistory } from '@/composables/use-ip-history.js';
|
||||
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
|
||||
import { filterHistoryDays, countryFacets, ipVersionCounts } from '@/utils/ip-history.js';
|
||||
import { filterHistoryDays, groupHistoryByIP, countryFacets, ipVersionCounts } from '@/utils/ip-history.js';
|
||||
import { formatIsoDate } from '@/utils/time-utils.js';
|
||||
import getCountryName from '@/data/country-name.js';
|
||||
import FitText from '@/components/widgets/FitText.vue';
|
||||
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Globe, History, ListFilter, Trash2 } from '@lucide/vue';
|
||||
import { ChevronDown, Globe, History, ListFilter, Trash2 } from '@lucide/vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
@@ -156,6 +198,7 @@ const openPanel = () => {
|
||||
};
|
||||
|
||||
const lang = computed(() => store.lang);
|
||||
const viewMode = ref('ip');
|
||||
|
||||
// Filters — flat toggle tags, any combination. Empty selection = that
|
||||
// dimension unfiltered. Country values are country codes plus the 'unknown'
|
||||
@@ -170,15 +213,23 @@ const countryFilter = ref([]); // country codes + 'unknown'
|
||||
const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer';
|
||||
const tagCountClass = 'text-muted-foreground group-data-[state=on]:text-primary-foreground/70';
|
||||
|
||||
const versionCounts = computed(() => ipVersionCounts(sortedDays.value));
|
||||
// Facets always cover the full retained history, with units matching the view.
|
||||
// Country counts can overlap: one IP may have been recorded in multiple countries.
|
||||
const facetOptions = computed(() => ({ uniqueIPs: viewMode.value === 'ip' }));
|
||||
const versionCounts = computed(() => ipVersionCounts(sortedDays.value, facetOptions.value));
|
||||
const showTypeFilter = computed(() => versionCounts.value.v4 > 0 && versionCounts.value.v6 > 0);
|
||||
const countries = computed(() => countryFacets(sortedDays.value));
|
||||
const countries = computed(() => countryFacets(sortedDays.value, facetOptions.value));
|
||||
const showCountryFilter = computed(() => countries.value.length > 1);
|
||||
|
||||
const displayDays = computed(() => filterHistoryDays(sortedDays.value, {
|
||||
versions: versionFilter.value.map((v) => (v === 'v6' ? 6 : 4)),
|
||||
countries: countryFilter.value.map((c) => (c === 'unknown' ? '' : c)),
|
||||
}));
|
||||
const displayGroups = computed(() => {
|
||||
if (viewMode.value === 'date') return displayDays.value;
|
||||
const entries = groupHistoryByIP(displayDays.value);
|
||||
return entries.length ? [{ day: null, entries }] : [];
|
||||
});
|
||||
|
||||
// Drop selections whose records vanished entirely (retention pruning,
|
||||
// clear-all), so stale hidden tags don't keep filtering the list.
|
||||
|
||||
@@ -1215,7 +1215,15 @@
|
||||
"UnknownCountry": "Unknown",
|
||||
"NoMatch": "No records match the selected filters.",
|
||||
"ClearAll": "Clear History",
|
||||
"ClearConfirm": "Click again to confirm"
|
||||
"ClearConfirm": "Click again to confirm",
|
||||
"ViewMode": "History view",
|
||||
"ByIP": "By IP",
|
||||
"ByDate": "By date",
|
||||
"UniqueIPCounts": "Tag counts: unique IPs across all retained records.",
|
||||
"DailyRecordCounts": "Tag counts: records across all retained dates (one per IP per day).",
|
||||
"LastSeen": "Last seen: {date}",
|
||||
"FirstSeen": "First seen: {date}",
|
||||
"RecordDates": "View recorded dates"
|
||||
},
|
||||
"ipInfos": {
|
||||
"Title": "IP Infos",
|
||||
|
||||
@@ -1215,7 +1215,15 @@
|
||||
"UnknownCountry": "Inconnu",
|
||||
"NoMatch": "Aucun enregistrement ne correspond aux filtres sélectionnés.",
|
||||
"ClearAll": "Effacer l'historique",
|
||||
"ClearConfirm": "Cliquez à nouveau pour confirmer"
|
||||
"ClearConfirm": "Cliquez à nouveau pour confirmer",
|
||||
"ViewMode": "Vue de l’historique",
|
||||
"ByIP": "Par IP",
|
||||
"ByDate": "Par date",
|
||||
"UniqueIPCounts": "Compteurs des filtres : IP uniques dans l’ensemble des enregistrements conservés.",
|
||||
"DailyRecordCounts": "Compteurs des filtres : enregistrements de toutes les dates conservées (un par IP et par jour).",
|
||||
"LastSeen": "Dernière détection : {date}",
|
||||
"FirstSeen": "Première détection : {date}",
|
||||
"RecordDates": "Voir les dates enregistrées"
|
||||
},
|
||||
"ipInfos": {
|
||||
"Title": "Infos IP",
|
||||
|
||||
@@ -1215,7 +1215,15 @@
|
||||
"UnknownCountry": "Desconhecido",
|
||||
"NoMatch": "Nenhum registro corresponde aos filtros selecionados.",
|
||||
"ClearAll": "Limpar histórico",
|
||||
"ClearConfirm": "Clique novamente para confirmar"
|
||||
"ClearConfirm": "Clique novamente para confirmar",
|
||||
"ViewMode": "Visualização do histórico",
|
||||
"ByIP": "Por IP",
|
||||
"ByDate": "Por data",
|
||||
"UniqueIPCounts": "Contagens dos filtros: IPs únicos em todos os registros mantidos.",
|
||||
"DailyRecordCounts": "Contagens dos filtros: registros de todas as datas mantidas (um por IP por dia).",
|
||||
"LastSeen": "Última detecção: {date}",
|
||||
"FirstSeen": "Primeira detecção: {date}",
|
||||
"RecordDates": "Ver datas registradas"
|
||||
},
|
||||
"ipInfos": {
|
||||
"Title": "Informações de IP",
|
||||
|
||||
@@ -1215,7 +1215,15 @@
|
||||
"UnknownCountry": "Неизвестно",
|
||||
"NoMatch": "Нет записей, соответствующих выбранным фильтрам.",
|
||||
"ClearAll": "Очистить историю",
|
||||
"ClearConfirm": "Нажмите ещё раз для подтверждения"
|
||||
"ClearConfirm": "Нажмите ещё раз для подтверждения",
|
||||
"ViewMode": "Вид истории",
|
||||
"ByIP": "По IP",
|
||||
"ByDate": "По дате",
|
||||
"UniqueIPCounts": "Числа в фильтрах: уникальные IP среди всех сохранённых записей.",
|
||||
"DailyRecordCounts": "Числа в фильтрах: записи за все сохранённые даты (одна на IP в день).",
|
||||
"LastSeen": "Последнее обнаружение: {date}",
|
||||
"FirstSeen": "Первое обнаружение: {date}",
|
||||
"RecordDates": "Показать даты записей"
|
||||
},
|
||||
"ipInfos": {
|
||||
"Title": "Сведения об IP",
|
||||
|
||||
@@ -1215,7 +1215,15 @@
|
||||
"UnknownCountry": "未知",
|
||||
"NoMatch": "沒有符合篩選條件的記錄。",
|
||||
"ClearAll": "清空記錄",
|
||||
"ClearConfirm": "再次點選確認清空"
|
||||
"ClearConfirm": "再次點選確認清空",
|
||||
"ViewMode": "歷史記錄檢視方式",
|
||||
"ByIP": "依 IP",
|
||||
"ByDate": "依日期",
|
||||
"UniqueIPCounts": "標籤數量:所有保留記錄中的不重複 IP 數。",
|
||||
"DailyRecordCounts": "標籤數量:所有保留日期的記錄數(同一 IP 每天計一筆)。",
|
||||
"LastSeen": "最近:{date}",
|
||||
"FirstSeen": "首次:{date}",
|
||||
"RecordDates": "檢視記錄日期"
|
||||
},
|
||||
"ipInfos": {
|
||||
"Title": "IP 資訊",
|
||||
|
||||
@@ -1215,7 +1215,15 @@
|
||||
"UnknownCountry": "未知",
|
||||
"NoMatch": "没有符合筛选条件的记录。",
|
||||
"ClearAll": "清空记录",
|
||||
"ClearConfirm": "再次点击确认清空"
|
||||
"ClearConfirm": "再次点击确认清空",
|
||||
"ViewMode": "历史记录视图",
|
||||
"ByIP": "按 IP",
|
||||
"ByDate": "按日期",
|
||||
"UniqueIPCounts": "标签数量:全部保留记录中的独立 IP 数。",
|
||||
"DailyRecordCounts": "标签数量:全部保留日期的记录数(同一 IP 每天计一条)。",
|
||||
"LastSeen": "最近:{date}",
|
||||
"FirstSeen": "首次:{date}",
|
||||
"RecordDates": "查看记录日期"
|
||||
},
|
||||
"ipInfos": {
|
||||
"Title": "IP 信息",
|
||||
|
||||
@@ -130,15 +130,50 @@ export const sortedHistoryDays = (history) =>
|
||||
.sort(([a], [b]) => (a < b ? 1 : -1))
|
||||
.map(([day, entries]) => ({ day, entries }));
|
||||
|
||||
// Aggregate retained (optionally filtered) records without changing storage.
|
||||
// Keep the latest matching record's metadata together: combining fields from
|
||||
// different days could invent a country/location/ASN combination never seen.
|
||||
export const groupHistoryByIP = (days) => {
|
||||
const byIp = new Map();
|
||||
for (const { day, entries } of [...days].sort((a, b) => b.day.localeCompare(a.day))) {
|
||||
for (const entry of entries) {
|
||||
if (!byIp.has(entry.ip)) {
|
||||
byIp.set(entry.ip, { ...entry, dates: new Set() });
|
||||
}
|
||||
byIp.get(entry.ip).dates.add(day);
|
||||
}
|
||||
}
|
||||
return [...byIp.values()]
|
||||
.map(({ dates, ...entry }) => {
|
||||
const recordedDays = [...dates];
|
||||
return {
|
||||
...entry,
|
||||
dates: recordedDays,
|
||||
firstSeen: recordedDays.at(-1),
|
||||
lastSeen: recordedDays[0],
|
||||
dayCount: recordedDays.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || a.ip.localeCompare(b.ip));
|
||||
};
|
||||
|
||||
// Aggregate entry counts per country over a sortedHistoryDays() list, most
|
||||
// entries first (code asc as tie-breaker). Entries without a country roll up
|
||||
// under ''. Takes the day list rather than the history object so facets can
|
||||
// be computed on an already-filtered view (e.g. after the IP-type filter).
|
||||
export const countryFacets = (days) => {
|
||||
// Unique mode counts each IP once per country it was recorded in, so a
|
||||
// historical country remains discoverable even if the latest record differs.
|
||||
export const countryFacets = (days, { uniqueIPs = false } = {}) => {
|
||||
const counts = new Map();
|
||||
const seen = new Map();
|
||||
for (const { entries } of days) {
|
||||
for (const { country } of entries) {
|
||||
for (const { ip, country } of entries) {
|
||||
const code = country || '';
|
||||
if (uniqueIPs) {
|
||||
if (!seen.has(code)) seen.set(code, new Set());
|
||||
if (seen.get(code).has(ip)) continue;
|
||||
seen.get(code).add(ip);
|
||||
}
|
||||
counts.set(code, (counts.get(code) || 0) + 1);
|
||||
}
|
||||
}
|
||||
@@ -159,12 +194,15 @@ export const distinctCountryCount = (history) => {
|
||||
return codes.size;
|
||||
};
|
||||
|
||||
// Entry counts per IP version over a sortedHistoryDays() list.
|
||||
export const ipVersionCounts = (days) => {
|
||||
// Entry counts per IP version, optionally deduplicated across all days.
|
||||
export const ipVersionCounts = (days, { uniqueIPs = false } = {}) => {
|
||||
let v4 = 0;
|
||||
let v6 = 0;
|
||||
const seen = new Set();
|
||||
for (const { entries } of days) {
|
||||
for (const { ip } of entries) {
|
||||
if (uniqueIPs && seen.has(ip)) continue;
|
||||
seen.add(ip);
|
||||
if (isIPv6(ip)) v6 += 1;
|
||||
else v4 += 1;
|
||||
}
|
||||
|
||||
+104
-1
@@ -1,6 +1,6 @@
|
||||
// Tests for the pure IP-history helpers behind the local (browser-only)
|
||||
// IP detection history: parsing/sanitizing the localStorage payload, per-day
|
||||
// merge with field back-fill, retention pruning, and render-order sorting.
|
||||
// merge with field back-fill, retention pruning, aggregation, facets and filtering.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
mergeIntoHistory,
|
||||
pruneHistory,
|
||||
sortedHistoryDays,
|
||||
groupHistoryByIP,
|
||||
countryFacets,
|
||||
distinctCountryCount,
|
||||
ipVersionCounts,
|
||||
@@ -187,6 +188,108 @@ describe('sortedHistoryDays', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupHistoryByIP', () => {
|
||||
it('collapses 90 days of one stable IP to one summary without modifying records', () => {
|
||||
const days = Array.from({ length: 90 }, (_, i) => ({
|
||||
day: localDayKey(new Date(2026, 6, 1 + i)),
|
||||
entries: [entry('192.0.2.1')],
|
||||
}));
|
||||
const before = structuredClone(days);
|
||||
const result = groupHistoryByIP(days);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].dayCount, 90);
|
||||
assert.equal(result[0].firstSeen, days[0].day);
|
||||
assert.equal(result[0].lastSeen, days[89].day);
|
||||
assert.deepEqual(result[0].dates, days.map(({ day }) => day).reverse());
|
||||
assert.deepEqual(days, before);
|
||||
});
|
||||
|
||||
it('counts distinct observed dates, not duplicates or unobserved days in the span', () => {
|
||||
const days = [
|
||||
{ day: '2026-07-01', entries: [entry('192.0.2.1'), entry('192.0.2.1')] },
|
||||
{ day: '2026-07-09', entries: [entry('192.0.2.1')] },
|
||||
];
|
||||
assert.deepEqual(groupHistoryByIP(days)[0], {
|
||||
...entry('192.0.2.1'),
|
||||
dates: ['2026-07-09', '2026-07-01'],
|
||||
firstSeen: '2026-07-01', lastSeen: '2026-07-09', dayCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts by last seen descending with a stable IP tie-breaker, regardless of input order', () => {
|
||||
const days = [
|
||||
{ day: '2026-07-01', entries: [entry('192.0.2.1')] },
|
||||
{ day: '2026-07-09', entries: [entry('2001:db8::1'), entry('192.0.2.2')] },
|
||||
];
|
||||
assert.deepEqual(groupHistoryByIP(days).map(({ ip }) => ip), [
|
||||
'192.0.2.2', '2001:db8::1', '192.0.2.1',
|
||||
]);
|
||||
assert.deepEqual(groupHistoryByIP([...days].reverse()), groupHistoryByIP(days));
|
||||
});
|
||||
|
||||
it('uses the latest matching snapshot without borrowing older geographic details', () => {
|
||||
const days = [
|
||||
{ day: '2026-07-01', entries: [entry('192.0.2.1', { country: 'US', location: 'New York', asn: 'AS1' })] },
|
||||
{ day: '2026-07-09', entries: [entry('192.0.2.1', { country: 'SG', asn: 'AS2' })] },
|
||||
];
|
||||
const [summary] = groupHistoryByIP(days);
|
||||
assert.equal(summary.country, 'SG');
|
||||
assert.equal(summary.location, '');
|
||||
assert.equal(summary.asn, 'AS2');
|
||||
const [historical] = groupHistoryByIP(filterHistoryDays(days, { countries: ['US'] }));
|
||||
assert.equal(historical.country, 'US');
|
||||
assert.equal(historical.location, 'New York');
|
||||
assert.equal(historical.lastSeen, '2026-07-01');
|
||||
assert.equal(historical.dayCount, 1);
|
||||
});
|
||||
|
||||
it('derives first seen and counts from retained records only', () => {
|
||||
const history = { version: 1, days: {
|
||||
'2026-07-01': [entry('192.0.2.1')],
|
||||
'2026-07-09': [entry('192.0.2.1')],
|
||||
} };
|
||||
const retained = pruneHistory(history, '2026-07-09', 1).history;
|
||||
const [summary] = groupHistoryByIP(sortedHistoryDays(retained));
|
||||
assert.equal(summary.firstSeen, '2026-07-09');
|
||||
assert.equal(summary.lastSeen, '2026-07-09');
|
||||
assert.equal(summary.dayCount, 1);
|
||||
assert.deepEqual(groupHistoryByIP([]), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unique IP facets', () => {
|
||||
const days = [
|
||||
{ day: '2026-07-09', entries: [entry('192.0.2.1', { country: 'SG' }), entry('2001:db8::1')] },
|
||||
{ day: '2026-07-08', entries: [entry('192.0.2.1', { country: 'SG' }), entry('2001:db8::1')] },
|
||||
{ day: '2026-07-01', entries: [entry('192.0.2.1', { country: 'US' })] },
|
||||
];
|
||||
|
||||
it('counts one IP per country while retaining every historical country', () => {
|
||||
const facets = countryFacets(days, { uniqueIPs: true });
|
||||
assert.deepEqual(facets, [
|
||||
{ code: '', count: 1 }, { code: 'SG', count: 1 }, { code: 'US', count: 1 },
|
||||
]);
|
||||
for (const facet of facets) {
|
||||
assert.equal(groupHistoryByIP(filterHistoryDays(days, { countries: [facet.code] })).length, facet.count);
|
||||
}
|
||||
assert.equal(countryFacets(days).find(({ code }) => code === 'SG').count, 2);
|
||||
assert.deepEqual(countryFacets([], { uniqueIPs: true }), []);
|
||||
});
|
||||
|
||||
it('deduplicates IP versions across days, with the original daily counts still available', () => {
|
||||
assert.deepEqual(ipVersionCounts(days, { uniqueIPs: true }), { v4: 1, v6: 1 });
|
||||
assert.deepEqual(ipVersionCounts(days), { v4: 3, v6: 2 });
|
||||
assert.deepEqual(ipVersionCounts([], { uniqueIPs: true }), { v4: 0, v6: 0 });
|
||||
});
|
||||
|
||||
it('combines country selections without counting a shared IP twice', () => {
|
||||
const filtered = filterHistoryDays(days, { countries: ['SG', 'US'], versions: [4] });
|
||||
const summaries = groupHistoryByIP(filtered);
|
||||
assert.equal(summaries.length, 1);
|
||||
assert.equal(summaries[0].dayCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// Two-day history spanning US / SG / no-country entries and both IP versions,
|
||||
// used by the filter suites below. Day 07-08: three v4 + one v6 (US);
|
||||
// day 07-07: one v4 (US).
|
||||
|
||||
Reference in new issue
Block a user