mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(themes): drop unreliable popularity/download stats from the store (#1050)
The install/download numbers came from jsDelivr CDN hits, which are structurally unreliable: the stats API caps at the top 100 files, so most themes report 0 and the figures jump from day to day. Showing them — and a popularity bar and a most-popular sort built on them — only misled users. Remove the stats meta box (popularity bar + download count), move the author back under the theme name, and keep the reliable git-based last-changed date inline. Sort is now Newest (default) or Alphabetical.
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Five-segment popularity bar, filled relative to the most-downloaded theme in
|
||||
* the catalogue (so the leader reads full and the rest scale against it). With
|
||||
* few downloads it sits near-empty and fills in organically as counts grow.
|
||||
* Decorative — the exact download number sits next to it as the real value.
|
||||
*/
|
||||
export function PopularityBar({ value, max }: { value: number; max: number }) {
|
||||
const filled = max > 0 ? Math.round((Math.max(0, value) / max) * 5) : 0;
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', gap: 3 }} aria-hidden="true">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 5,
|
||||
borderRadius: 2,
|
||||
background: i < filled ? 'var(--accent)' : 'var(--bg-hover)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -189,25 +189,19 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sorts by most popular, newest and name, and shows the download count', async () => {
|
||||
it('defaults to newest and sorts alphabetically', async () => {
|
||||
const themes = [
|
||||
mkTheme('a', 'Alpha', { installs: 10, updatedAt: '2026-06-01T00:00:00Z' }),
|
||||
mkTheme('b', 'Bravo', { installs: 500, updatedAt: '2026-06-05T00:00:00Z' }),
|
||||
mkTheme('c', 'Charlie', { installs: 0, updatedAt: '2026-06-03T00:00:00Z' }),
|
||||
mkTheme('a', 'Alpha', { updatedAt: '2026-06-01T00:00:00Z' }),
|
||||
mkTheme('b', 'Bravo', { updatedAt: '2026-06-05T00:00:00Z' }),
|
||||
mkTheme('c', 'Charlie', { updatedAt: '2026-06-03T00:00:00Z' }),
|
||||
];
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
|
||||
const { container } = renderWithProviders(<ThemeStoreSection />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await screen.findByText('Bravo');
|
||||
// Default sort is most-downloaded first: Bravo (500) > Alpha (10) > Charlie (0).
|
||||
expect(rowNames(container)).toEqual(['Bravo', 'Alpha', 'Charlie']);
|
||||
// The download count renders in the meta panel.
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
|
||||
// Newest first: Bravo (06-05) > Charlie (06-03) > Alpha (06-01).
|
||||
await selectSort(user, container, 'Newest');
|
||||
await waitFor(() => expect(rowNames(container)).toEqual(['Bravo', 'Charlie', 'Alpha']));
|
||||
// Default sort is newest first: Bravo (06-05) > Charlie (06-03) > Alpha (06-01).
|
||||
expect(rowNames(container)).toEqual(['Bravo', 'Charlie', 'Alpha']);
|
||||
|
||||
// Alphabetical.
|
||||
await selectSort(user, container, 'Alphabetical');
|
||||
|
||||
@@ -5,7 +5,6 @@ import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import CoverLightbox from '../CoverLightbox';
|
||||
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
|
||||
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
|
||||
import { PopularityBar } from './PopularityBar';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import { formatRelativeTime } from '../../utils/format/relativeTime';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
@@ -20,7 +19,7 @@ import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
||||
|
||||
type ModeFilter = 'all' | 'dark' | 'light';
|
||||
type SortMode = 'popular' | 'newest' | 'name';
|
||||
type SortMode = 'newest' | 'name';
|
||||
|
||||
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
|
||||
|
||||
@@ -61,7 +60,7 @@ export function ThemeStoreSection() {
|
||||
const [stale, setStale] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<ModeFilter>('all');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('popular');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
||||
const [page, setPage] = useState(1);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
@@ -98,14 +97,6 @@ export function ThemeStoreSection() {
|
||||
return m;
|
||||
}, [installed]);
|
||||
|
||||
// Scale the popularity bar against the most-downloaded theme across the whole
|
||||
// catalogue (not the filtered view) so the bar means the same thing regardless
|
||||
// of search/filter.
|
||||
const maxInstalls = useMemo(
|
||||
() => Math.max(1, ...(themes || []).map(th => th.installs ?? 0)),
|
||||
[themes],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!themes) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -120,13 +111,10 @@ export function ThemeStoreSection() {
|
||||
);
|
||||
});
|
||||
// Name is the stable tie-breaker — keeps ordering deterministic when many
|
||||
// themes share the same (often 0) download count.
|
||||
// themes share the same last-modified date.
|
||||
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
|
||||
if (sortMode === 'name') return matched.sort(byName);
|
||||
if (sortMode === 'newest') {
|
||||
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
|
||||
}
|
||||
return matched.sort((a, b) => (b.installs ?? 0) - (a.installs ?? 0) || byName(a, b));
|
||||
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
|
||||
}, [themes, query, mode, sortMode]);
|
||||
|
||||
// A changed filter can shrink the result set below the current page; reset to
|
||||
@@ -161,7 +149,6 @@ export function ThemeStoreSection() {
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'popular', label: t('settings.themeStoreSortPopular') },
|
||||
{ value: 'newest', label: t('settings.themeStoreSortNewest') },
|
||||
{ value: 'name', label: t('settings.themeStoreSortName') },
|
||||
];
|
||||
@@ -335,11 +322,18 @@ export function ThemeStoreSection() {
|
||||
)}
|
||||
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.themeStoreByAuthor', { author: th.author })}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
|
||||
{th.description}
|
||||
</div>
|
||||
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 18 }}>
|
||||
{th.updatedAt && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 8 }}>
|
||||
{t('settings.themeStoreLastChanged')}: {formatRelativeTime(th.updatedAt, i18n.language)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
|
||||
{!isInstalled && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -385,31 +379,6 @@ export function ThemeStoreSection() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="theme-store-meta"
|
||||
style={{ flexShrink: 0, width: 190, display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 12px', background: 'var(--bg-deep, var(--bg-elevated))', border: '1px solid var(--border)', borderRadius: 'var(--radius-md, 10px)' }}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.themeStoreAuthor')}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{th.author}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{t('settings.themeStorePopularity')}</div>
|
||||
<PopularityBar value={th.installs ?? 0} max={maxInstalls} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.themeStoreDownloads')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600 }}>{(th.installs ?? 0).toLocaleString(i18n.language)}</div>
|
||||
</div>
|
||||
{th.updatedAt && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.themeStoreLastChanged')}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)' }}>{formatRelativeTime(th.updatedAt, i18n.language)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user