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 }