refactor: split non-component exports (react-refresh) into their own modules

Removes the last react-refresh disables by splitting exports instead:
- contexts/language.ts: Language/Translations types + LanguageContext +
  useTranslation/useT (LanguageProvider stays a component-only file); 12
  importers repointed.
- lib/sortOptions.ts: calculatePopularity + calculateDistance (out of SortDropdown)
- lib/trustFilter.ts: TrustFilterLevel + filterByTrustLevel (out of TrustFilter)
- EventCountdown: deleted the unused useEventStatus hook
- NewsletterPage: dropped the NEWSLETTER_ISSUES re-export shim
- AGENTS.md updated (lint stays clean with no blanket disables)

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-26 23:29:42 +07:00
parent 584561e390
commit b2a6108eeb
22 changed files with 186 additions and 196 deletions

View file

@ -95,9 +95,10 @@ session with **no other memory of this project**. So:
- **Baselines** (verified 2026-09-26): `npm test` → **0 errors, 0 warnings**,
**101 tests**, build ok. `package.json` version **1.4.5**.
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
· `no-unused-vars` = 0 · `react-refresh` = 0 (the last were intentional
component+hook/helper colocations carrying a justified file-level disable).
Keep it at zero — don't reintroduce any.
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
`lib/trustFilter.ts`) and unused hooks/functions deleted — no blanket
disables. Keep it at zero — don't reintroduce any.
- ⚠️ Do **not** mass-fix `no-unused-vars` by name or blanket rename — shadowed
names and typed shorthand destructuring make it unsafe. Fix position-precise.
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or

View file

@ -4,7 +4,7 @@ import { AlertTriangle, X, Download, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import {
Sheet,
SheetContent,

View file

@ -7,7 +7,7 @@ import {
type NavTab,
} from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';

View file

@ -1,5 +1,5 @@
import { useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ArrowUp, ArrowDown, Eye, EyeOff, Lock, RotateCcw, Monitor } from 'lucide-react';

View file

@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components -- EventCountdown component + useEventStatus hook are colocated by design (dev-only HMR rule). */
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
@ -87,15 +86,3 @@ export function EventCountdown({
</span>
);
}
/** Standalone helper to get just the status (for conditional styling). */
export function useEventStatus(start: number, end?: number): EventStatus {
const [tick, forceTick] = useState(0);
useEffect(() => {
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
return () => clearInterval(id);
}, []);
// `tick` forces recompute on the interval (it is not used inside the memo).
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
}

View file

@ -6,7 +6,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTranslation, type Language } from '@/contexts/LanguageContext';
import { useTranslation, type Language } from '@/contexts/language';
import { cn } from '@/lib/utils';
interface LanguageOption {

View file

@ -17,7 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { LanguageToggle } from '@/components/LanguageToggle';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import {

View file

@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import {
Sheet,
SheetContent,

View file

@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components -- SortDropdown component + its sort option constants/helpers are intentionally colocated (dev-only HMR rule). */
import { ArrowUpDown, Clock, TrendingUp, MapPin, Zap, Shield } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
@ -100,43 +99,3 @@ export function SortDropdown({
</Select>
);
}
/**
* Helper function to calculate popularity score for sorting
* Based on comments, reactions, and other engagement metrics
*/
export function calculatePopularity(
commentCount: number = 0,
reactionCount: number = 0,
zapCount: number = 0
): number {
// Weighted popularity: comments worth 3x, reactions 1x, zaps 5x
return (commentCount * 3) + (reactionCount * 1) + (zapCount * 5);
}
/**
* Calculate distance between two coordinates in kilometers
* Uses Haversine formula
*/
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRad(degrees: number): number {
return degrees * (Math.PI / 180);
}

View file

@ -21,11 +21,11 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName';
import { TrustFilter } from '@/components/TrustFilter';
import {
TrustFilter,
filterByTrustLevel,
type TrustFilterLevel,
} from '@/components/TrustFilter';
} from '@/lib/trustFilter';
import {
useSpotChat,
useSendSpotChat,

View file

@ -1,6 +1,6 @@
/* eslint-disable react-refresh/only-export-components -- TrustFilter component + its level constants/helpers are intentionally colocated (dev-only HMR rule). */
import { Shield, CheckCircle2, Users } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { TrustFilterLevel } from '@/lib/trustFilter';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@ -12,8 +12,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
export type TrustFilterLevel = 'all' | 'verified' | 'trusted' | 'following';
interface TrustFilterProps {
value: TrustFilterLevel;
onChange: (value: TrustFilterLevel) => void;
@ -137,26 +135,3 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
/**
* Helper function to filter items by trust score
*/
export function filterByTrustLevel<T extends { pubkey: string }>(
items: T[],
level: TrustFilterLevel,
getTrustScore: (pubkey: string) => number | undefined
): T[] {
if (level === 'all') return items;
return items.filter((item) => {
const score = getTrustScore(item.pubkey);
if (score === undefined) return false;
switch (level) {
case 'verified':
return score >= 90;
case 'trusted':
return score >= 60;
case 'following':
return score === 90 || score === 100;
default:
return true;
}
});
}

View file

@ -1,75 +1,21 @@
/* eslint-disable react-refresh/only-export-components -- LanguageProvider + useTranslation hook + context are colocated by design (idiomatic context module; dev-only HMR rule). */
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
// Supported languages
export type Language = 'en' | 'es';
// Translation key type - allows nested keys like 'nav.home'
type TranslationKey = string;
// Translation values can be strings or nested objects
type TranslationValue = string | { [key: string]: TranslationValue };
// Translations structure
export type Translations = {
[key: string]: TranslationValue;
};
interface LanguageContextType {
/** Current language */
language: Language;
/** Set the current language */
setLanguage: (lang: Language) => void;
/** Translate a key - supports dot notation for nested keys */
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
/** Check if current language is RTL (for future Arabic support, etc.) */
isRTL: boolean;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
// Import translations
import en from '@/i18n/en';
import es from '@/i18n/es';
const translations: Record<Language, Translations> = { en, es };
/**
* Get nested value from object using dot notation
* e.g., getNestedValue({ nav: { home: 'Home' } }, 'nav.home') => 'Home'
*/
function getNestedValue(obj: Translations, path: string): string | undefined {
const keys = path.split('.');
let current: TranslationValue = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return undefined;
}
}
return typeof current === 'string' ? current : undefined;
}
/**
* Replace parameters in translation string
* e.g., interpolate('Hello {{name}}!', { name: 'World' }) => 'Hello World!'
*/
function interpolate(str: string, params?: Record<string, string | number>): string {
if (!params) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
return params[key]?.toString() ?? `{{${key}}}`;
});
}
import {
LanguageContext,
getNestedValue,
interpolate,
translations,
type Language,
} from './language';
interface LanguageProviderProps {
children: ReactNode;
}
/**
* Provides language state + translation. The context, types and hooks live in
* `./language` so this file exports only the component (React Fast Refresh).
*/
export function LanguageProvider({ children }: LanguageProviderProps) {
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', 'en');
const [language, setLanguageState] = useState<Language>(storedLanguage);
@ -91,14 +37,14 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
document.documentElement.lang = language;
}, [language]);
const t = useCallback((key: TranslationKey, params?: Record<string, string | number>): string => {
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
// Try to get translation in current language
const translation = getNestedValue(translations[language], key);
if (translation) {
return interpolate(translation, params);
}
// Fallback to English
if (language !== 'en') {
const fallback = getNestedValue(translations.en, key);
@ -106,7 +52,7 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
return interpolate(fallback, params);
}
}
// Return key if no translation found (helps identify missing translations)
console.warn(`Missing translation for key: ${key}`);
return key;
@ -121,24 +67,3 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
</LanguageContext.Provider>
);
}
/**
* Hook to access translation functions and language state
*/
export function useTranslation() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useTranslation must be used within a LanguageProvider');
}
return context;
}
/**
* Hook to get just the translation function (convenience)
*/
export function useT() {
const { t } = useTranslation();
return t;
}

75
src/contexts/language.ts Normal file
View file

@ -0,0 +1,75 @@
import { createContext, useContext } from 'react';
import en from '@/i18n/en';
import es from '@/i18n/es';
/**
* Language context + types + hooks. Split from the `LanguageProvider` component
* so the component file exports only a component (React Fast Refresh), per the
* `react-refresh/only-export-components` rule.
*/
export type Language = 'en' | 'es';
type TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue };
export type Translations = {
[key: string]: TranslationValue;
};
export interface LanguageContextType {
/** Current language */
language: Language;
/** Set the current language */
setLanguage: (lang: Language) => void;
/** Translate a key - supports dot notation for nested keys */
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
/** Check if current language is RTL (for future Arabic support, etc.) */
isRTL: boolean;
}
export const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const translations: Record<Language, Translations> = { en, es };
/** Get a nested value from an object using dot notation (`nav.home`). */
export function getNestedValue(obj: Translations, path: string): string | undefined {
const keys = path.split('.');
let current: TranslationValue = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return undefined;
}
}
return typeof current === 'string' ? current : undefined;
}
/** Replace `{{param}}` placeholders in a translation string. */
export function interpolate(str: string, params?: Record<string, string | number>): string {
if (!params) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
return params[key]?.toString() ?? `{{${key}}}`;
});
}
/** Hook to access translation functions and language state. */
export function useTranslation() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useTranslation must be used within a LanguageProvider');
}
return context;
}
/** Hook to get just the translation function (convenience). */
export function useT() {
const { t } = useTranslation();
return t;
}

36
src/lib/sortOptions.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* Sort/geo helpers shared across pages. Kept out of SortDropdown.tsx so that
* component file exports only a component (React Fast Refresh).
*/
/** Weighted popularity score: comments 3x, reactions 1x, zaps 5x. */
export function calculatePopularity(
commentCount = 0,
reactionCount = 0,
zapCount = 0,
): number {
return commentCount * 3 + reactionCount * 1 + zapCount * 5;
}
/** Great-circle distance between two coordinates, in kilometres (Haversine). */
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRad(degrees: number): number {
return degrees * (Math.PI / 180);
}

34
src/lib/trustFilter.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Trust-filter levels + filtering helper. Kept out of TrustFilter.tsx so that
* component file exports only a component (React Fast Refresh).
*/
export type TrustFilterLevel = 'all' | 'verified' | 'trusted' | 'following';
/**
* Filter items by the web-of-trust score of their author.
* `verified` ≥ 90, `trusted` ≥ 60, `following` is the top of the source's graph.
*/
export function filterByTrustLevel<T extends { pubkey: string }>(
items: T[],
level: TrustFilterLevel,
getTrustScore: (pubkey: string) => number | undefined,
): T[] {
if (level === 'all') return items;
return items.filter((item) => {
const score = getTrustScore(item.pubkey);
if (score === undefined) return false;
switch (level) {
case 'verified':
return score >= 90;
case 'trusted':
return score >= 60;
case 'following':
return score === 90 || score === 100;
default:
return true;
}
});
}

View file

@ -17,7 +17,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useTours } from '@/hooks/useTours';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
import { useCrew } from '@/hooks/useCrews';

View file

@ -12,7 +12,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea';
import { QualityMeter } from '@/components/QualityMeter';
import { TagInput } from '@/components/TagInput';

View file

@ -10,7 +10,7 @@ import { EventsCalendar } from '@/components/EventsCalendar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
import { type SortOption } from '@/components/SortDropdown';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useBatchEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
@ -19,7 +19,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowing } from '@/hooks/useFollowing';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useCommentCounts } from '@/hooks/useCommentCounts';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import {
EVENT_TYPE_LABELS,
TOUR_CATEGORIES,

View file

@ -14,7 +14,7 @@ import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';

View file

@ -5,13 +5,13 @@ import { Layout } from '@/components/Layout';
import { LazyMount } from '@/components/LazyMount';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
import { type SortOption } from '@/components/SortDropdown';
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import {
COACH_SKILL_LABELS,

View file

@ -7,8 +7,10 @@ import { Layout } from '@/components/Layout';
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { WeatherToggle } from '@/components/WeatherToggle';
import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown';
import { TrustFilter } from '@/components/TrustFilter';
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
import { calculateDistance } from '@/lib/sortOptions';
import { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -39,7 +41,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/LanguageContext';
import { useTranslation } from '@/contexts/language';
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO } from '@/lib/types';
import type { SkateSpot } from '@/lib/types';
import ngeohash from 'ngeohash';

View file

@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components -- NewsletterPage component + its newsletter data exports are intentionally colocated (dev-only HMR rule). */
import { useEffect, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
@ -43,9 +42,6 @@ import { isAdminPubkey } from '@/lib/admins';
// ─── Newsletter issue structure ───────────────────────────────────────────────
//
import { NEWSLETTER_ISSUES, type NewsletterUpdate, type SpotOfTheWeek, type NewsletterIssue } from '@/lib/newsletterIssues';
// Preserve the previous public surface for existing importers.
export { NEWSLETTER_ISSUES } from '@/lib/newsletterIssues';
export type { NewsletterUpdate, SpotOfTheWeek, NewsletterIssue } from '@/lib/newsletterIssues';
// ─── Nostr post preview + publish ────────────────────────────────────────────