/ src / lib / utils / grouping.ts
grouping.ts
  1  import { type MastodonAccount, getAccountDomain } from '$lib/mastodon';
  2  import { friendkit } from '$lib/store.svelte';
  3  
  4  interface AccountGroup {
  5  	label: string;
  6  	accounts: MastodonAccount[];
  7  }
  8  
  9  // ---------------------------------------------------------------------------
 10  // Last-status grouping
 11  // ---------------------------------------------------------------------------
 12  
 13  export function groupByLastStatus(
 14  	accounts: MastodonAccount[],
 15  	direction: 'asc' | 'desc'
 16  ): AccountGroup[] {
 17  	const now = new Date();
 18  	const currentYear = now.getFullYear();
 19  	const currentMonth = now.getMonth(); // 0 = Jan, 1 = Feb
 20  
 21  	const labelMap = new Map<string, MastodonAccount[]>();
 22  
 23  	// Pre-seed known recent groups to maintain order
 24  	const recentLabels = ['Today', '7 days', '30 days'];
 25  	recentLabels.forEach((l) => labelMap.set(l, []));
 26  
 27  	const neverGroup: MastodonAccount[] = [];
 28  
 29  	accounts.forEach((account) => {
 30  		if (!account.last_status_at) {
 31  			neverGroup.push(account);
 32  			return;
 33  		}
 34  
 35  		const date = new Date(account.last_status_at);
 36  		const lastYear = date.getFullYear();
 37  		const diffDays = (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24);
 38  
 39  		let groupLabel = '';
 40  		if (diffDays < 1) groupLabel = 'Today';
 41  		else if (diffDays < 7) groupLabel = '7 days';
 42  		else if (diffDays < 30) groupLabel = '30 days';
 43  		else if (currentYear === lastYear) {
 44  			// Only show current year if we're in Feb+
 45  			groupLabel = currentMonth >= 1 ? String(currentYear) : '30 days';
 46  		} else {
 47  			groupLabel = String(lastYear);
 48  		}
 49  
 50  		if (!labelMap.has(groupLabel)) {
 51  			labelMap.set(groupLabel, []);
 52  		}
 53  		labelMap.get(groupLabel)!.push(account);
 54  	});
 55  
 56  	// Build the final list.
 57  	// 1. Start with recent windows that have accounts.
 58  	const result: AccountGroup[] = recentLabels
 59  		.map((label) => ({ label, accounts: labelMap.get(label)! }))
 60  		.filter((g) => g.accounts.length > 0);
 61  
 62  	// 2. Add year-based groups (sorted descending by year)
 63  	const yearGroups = Array.from(labelMap.keys())
 64  		.filter((label) => !recentLabels.includes(label))
 65  		.sort((a, b) => Number(b) - Number(a))
 66  		.map((label) => ({ label, accounts: labelMap.get(label)! }));
 67  
 68  	result.push(...yearGroups);
 69  
 70  	// 3. Add 'Never' group at the end
 71  	if (neverGroup.length > 0) {
 72  		result.push({ label: 'Never ever', accounts: neverGroup });
 73  	}
 74  
 75  	return direction === 'asc' ? [...result].reverse() : result;
 76  }
 77  
 78  // ---------------------------------------------------------------------------
 79  // Account-age grouping
 80  // ---------------------------------------------------------------------------
 81  
 82  const CREATED_GROUPS: { label: string; test: (ageDays: number) => boolean }[] = [
 83  	{ label: '5+ years', test: (d) => d >= 365 * 5 },
 84  	{ label: '2–5 years', test: (d) => d >= 365 * 2 && d < 365 * 5 },
 85  	{ label: '1–2 years', test: (d) => d >= 365 && d < 365 * 2 },
 86  	{ label: '6–12 months', test: (d) => d >= 180 && d < 365 },
 87  	{ label: '< 6 months', test: (d) => d < 180 }
 88  ];
 89  
 90  export function groupByCreated(
 91  	accounts: MastodonAccount[],
 92  	direction: 'asc' | 'desc'
 93  ): AccountGroup[] {
 94  	const now = Date.now();
 95  
 96  	function ageDays(account: MastodonAccount): number {
 97  		return (now - new Date(account.created_at).getTime()) / (1000 * 60 * 60 * 24);
 98  	}
 99  
100  	const groups = CREATED_GROUPS.map(({ label, test }) => ({
101  		label,
102  		accounts: accounts.filter((a) => test(ageDays(a)))
103  	})).filter((g) => g.accounts.length > 0);
104  
105  	return direction === 'asc' ? [...groups].reverse() : groups;
106  }
107  
108  // ---------------------------------------------------------------------------
109  // Follower-count grouping (logarithmic / landmark scale)
110  // ---------------------------------------------------------------------------
111  
112  const FOLLOWER_GROUPS: { label: string; test: (n: number) => boolean }[] = [
113  	{ label: '100k+', test: (n) => n > 100_000 },
114  	{ label: '10k – 100k', test: (n) => n > 10_000 && n <= 100_000 },
115  	{ label: '1k – 10k', test: (n) => n > 1_000 && n <= 10_000 },
116  	{ label: '101 – 1 000', test: (n) => n > 100 && n <= 1_000 },
117  	{ label: '11 – 100', test: (n) => n > 10 && n <= 100 },
118  	{ label: '1 – 10', test: (n) => n >= 1 && n <= 10 },
119  	{ label: 'No followers', test: (n) => n === 0 }
120  ];
121  
122  export function groupByFollowers(
123  	accounts: MastodonAccount[],
124  	direction: 'asc' | 'desc'
125  ): AccountGroup[] {
126  	const groups = FOLLOWER_GROUPS.map(({ label, test }) => ({
127  		label,
128  		accounts: accounts.filter((a) => test(a.followers_count ?? 0))
129  	})).filter((g) => g.accounts.length > 0);
130  
131  	// FOLLOWER_GROUPS is already highest-first; asc = reverse
132  	return direction === 'asc' ? [...groups].reverse() : groups;
133  }
134  
135  // ---------------------------------------------------------------------------
136  // Server grouping
137  // ---------------------------------------------------------------------------
138  
139  export function groupByServer(
140  	accounts: MastodonAccount[],
141  	direction: 'asc' | 'desc'
142  ): AccountGroup[] {
143  	const domainMap = new Map<string, MastodonAccount[]>();
144  
145  	accounts.forEach((account) => {
146  		const domain = getAccountDomain(account, friendkit.instance);
147  		if (!domainMap.has(domain)) {
148  			domainMap.set(domain, []);
149  		}
150  		domainMap.get(domain)!.push(account);
151  	});
152  
153  	const allGroups = Array.from(domainMap.entries()).map(([domain, accounts]) => ({
154  		label: domain,
155  		accounts
156  	}));
157  
158  	const mainGroups = allGroups
159  		.filter((g) => g.accounts.length > 1)
160  		.sort((a, b) => b.accounts.length - a.accounts.length);
161  
162  	const otherAccounts = allGroups.filter((g) => g.accounts.length === 1).flatMap((g) => g.accounts);
163  
164  	const groups = [...mainGroups];
165  	if (otherAccounts.length > 0) {
166  		groups.push({
167  			label: 'Other',
168  			accounts: otherAccounts
169  		});
170  	}
171  
172  	return direction === 'asc' ? [...groups].reverse() : groups;
173  }
174  
175  // ---------------------------------------------------------------------------
176  // Moved grouping
177  // ---------------------------------------------------------------------------
178  
179  export function groupByMoved(
180  	accounts: MastodonAccount[],
181  	direction: 'asc' | 'desc'
182  ): AccountGroup[] {
183  	const moved = accounts.filter((a) => !!a.moved);
184  	const active = accounts.filter((a) => !a.moved);
185  
186  	const groups: AccountGroup[] = [];
187  	if (moved.length > 0) groups.push({ label: 'Account has moved', accounts: moved });
188  	if (active.length > 0) groups.push({ label: 'Account is active', accounts: active });
189  
190  	return direction === 'asc' ? groups : [...groups].reverse();
191  }