Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,53 @@
form; this file localises times and adds push notifications and passkeys where the
browser has them. */

/* ----------------------------------------------------------- list filter -- */

/**
* Filter a list of leagues or teams as you type.
*
* Every word must appear somewhere in the row's `data-filter` text, in any order
* and ignoring case: "city man" keeps Manchester City, "MAN" keeps it too. The
* box is server-rendered `hidden` and only shown here, so with JavaScript off
* nobody sees a control that does nothing.
*/
function listFilterWords(query) {
return String(query || '')
.toLocaleLowerCase()
.split(/\s+/)
.filter(Boolean);
}

function listFilterMatches(text, words) {
const hay = String(text || '').toLocaleLowerCase();
return words.every((w) => hay.includes(w));
}

function initListFilters(root = document) {
for (const box of root.querySelectorAll('[data-list-filter]')) {
if (box.dataset.ready) continue;
const list = document.getElementById(box.dataset.listFilter);
const input = box.querySelector('input');
if (!list || !input) continue;
box.dataset.ready = '1';
box.hidden = false;
const empty = box.querySelector('.list-filter-empty');
const apply = () => {
const words = listFilterWords(input.value);
let shown = 0;
for (const li of list.children) {
const ok = listFilterMatches(li.dataset.filter ?? li.textContent, words);
li.hidden = !ok;
if (ok) shown++;
}
if (empty) empty.hidden = shown > 0;
};
input.addEventListener('input', apply);
// A value the browser restored on back/forward must filter too.
if (input.value) apply();
}
}

/* ------------------------------------------------------------ local time -- */

/**
Expand Down Expand Up @@ -880,6 +927,7 @@ function initNavigation() {
if (push) history.pushState({}, '', url);
window.scrollTo(0, 0);
localiseTimes();
initListFilters();
initMarketTabs();
initOwnChannelActions();
initInlinePlayer();
Expand Down Expand Up @@ -919,6 +967,7 @@ function initNavigation() {
}

localiseTimes();
initListFilters();
reportTimezone();
initMarketTabs();
initOwnChannelActions();
Expand Down
18 changes: 17 additions & 1 deletion apps/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2956,4 +2956,20 @@ ul.group-grid .meta {
--live: oklch(0.72 0.19 25);
--ok: oklch(0.78 0.16 155);
}
}
}
/* ---- league / team list filter (shown by app.js) ---- */
.list-filter {
margin: 0.75rem 0;
}
.list-filter input[type="search"] {
max-width: 24rem;
}
.list-filter-empty {
margin-top: 0.5rem;
}
ul.sports > li[hidden],
ul.leagues > li[hidden],
ul.teams > li[hidden],
ul.chips > li[hidden] {
display: none;
}
20 changes: 19 additions & 1 deletion apps/web/src/views/components.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -609,9 +609,27 @@ export const EventList = ({ events, emptyText, showBroadcast = false, showOdds =
</ul>
);

/**
* A filter box for a list of leagues or teams.
*
* Every word typed has to appear in a row, in any order and ignoring case, so
* "city man" finds Manchester City. Rendered `hidden` and switched on by app.js:
* without JavaScript the box would do nothing, and a control that does nothing
* is worse than no control. Rows match on their `data-filter` text, which is the
* name alone, so "upcoming" or "follow" never matches every row.
*/
export const ListFilter = ({ target, label }) => (
<div class="list-filter" data-list-filter={target} hidden>
<input type="search" aria-label={label} placeholder={`${label}…`} autocomplete="off" />
<p class="muted small list-filter-empty" hidden>
Nothing matches.
</p>
</div>
);

/** A team in the follow picker. */
export const TeamRow = ({ team, user, next }) => (
<li class="team">
<li class="team" data-filter={team.display_name}>
{team.logo_url ? (
<img src={team.logo_url} alt="" loading="lazy" width="28" height="28" />
) : (
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/views/feeds.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { brand, Word } from '@tipoff/config';
import { ListFilter } from './components.jsx';
import { Layout } from './Layout.jsx';

/**
Expand Down Expand Up @@ -31,9 +32,10 @@ export const Feeds = ({ user, sports, leagues }) => (
</ul>

<h2>By {brand.words.category}</h2>
<ul class="sports">
<ListFilter target="feed-sports" label={`Filter ${brand.words.categories}`} />
<ul class="sports" id="feed-sports">
{sports.map((s) => (
<li>
<li data-filter={s.sport.replace(/-/g, ' ')}>
<a href={`/feeds/sport/${s.sport}.xml`}>
<strong>{s.sport.replace(/-/g, ' ')}</strong>
<span class="muted">
Expand All @@ -50,9 +52,10 @@ export const Feeds = ({ user, sports, leagues }) => (
and a calendar at <code>/calendar/league/&lt;slug&gt;.ics</code>, whether or not it is listed
here.
</p>
<ul class="leagues">
<ListFilter target="feed-leagues" label={`Filter ${brand.words.collections}`} />
<ul class="leagues" id="feed-leagues">
{leagues.map((l) => (
<li>
<li data-filter={l.name}>
<a href={`/feeds/league/${l.slug}.xml`}>{l.name}</a>
<span class="muted small">
{l.upcoming} upcoming · <a href={`/calendar/league/${l.slug}.ics`}>calendar</a>
Expand Down
39 changes: 26 additions & 13 deletions apps/web/src/views/pages.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile';
import { brand, href, Word } from '@tipoff/config';
import { keyVaultCardHtml } from '@profullstack/sealed-secrets/vault-card';
import { brand, href, Word } from '@tipoff/config';
import { raw } from 'hono/html';
import { assetUrl } from '../lib/asset-version.js';
import { coinpayConfig } from '../lib/coinpay-signin.js';
Expand All @@ -11,6 +11,7 @@ import {
EventList,
FollowButton,
KickoffTime,
ListFilter,
LocalTime,
OddsPanel,
oddsOf,
Expand Down Expand Up @@ -781,9 +782,10 @@ export const SportsIndex = ({
<li>{Word.collection}</li>
<li>{Word.participant}</li>
</ol>
<ul class="sports">
<ListFilter target="sports-list" label={`Filter ${brand.words.categories}`} />
<ul class="sports" id="sports-list">
{sports.map((s) => (
<li>
<li data-filter={`${categoryLabel(s.sport)} ${s.sport}`}>
<a href={href.category(s.sport)}>
<strong>{categoryLabel(s.sport)}</strong>
<span class="muted">
Expand Down Expand Up @@ -1069,9 +1071,10 @@ export const SportPage = ({
heading={`Watch ${name} now`}
blurb="Live channels, playing here. No account, nothing to install."
/>
<ul class="leagues">
<ListFilter target="leagues-list" label={`Filter ${brand.words.collections}`} />
<ul class="leagues" id="leagues-list">
{leagues.map((l) => (
<li>
<li data-filter={l.name}>
<a href={href.collection(l.slug)}>{l.name}</a>
<FollowButton
user={user}
Expand Down Expand Up @@ -1173,11 +1176,14 @@ export const LeaguePage = ({
{teams.length === 0 ? (
<p class="empty">{brand.copy.emptyParticipants}</p>
) : (
<ul class="teams">
{teams.map((t) => (
<TeamRow team={t} user={user} next={href.collection(league.slug)} />
))}
</ul>
<>
<ListFilter target="teams-list" label={`Filter ${brand.words.participants}`} />
<ul class="teams" id="teams-list">
{teams.map((t) => (
<TeamRow team={t} user={user} next={href.collection(league.slug)} />
))}
</ul>
</>
)}

<h2>Upcoming fixtures</h2>
Expand Down Expand Up @@ -1539,9 +1545,10 @@ export const Following = ({ user, events, follows, cleared, calendarUrl }) => (
</button>
</form>
</div>
<ul class="chips">
<ListFilter target="follows-list" label="Filter what you follow" />
<ul class="chips" id="follows-list">
{follows.map((f) => (
<li class="chip">
<li class="chip" data-filter={f.label}>
{f.label}
<form method="post" action="/api/unfollow" class="inline">
<input type="hidden" name="subject_type" value={f.subject_type} />
Expand Down Expand Up @@ -3236,7 +3243,13 @@ export const Settings = ({
{contactError ? <p class="feedback error">{contactError}</p> : null}
{contactSaved ? <p class="feedback ok">Contact details saved.</p> : null}
<form method="post" action="/api/settings/contact">
{raw(renderProfileFields({ fields: SIGNUP_FIELDS, values: contact ?? {}, importOpenProfile: true }))}
{raw(
renderProfileFields({
fields: SIGNUP_FIELDS,
values: contact ?? {},
importOpenProfile: true,
}),
)}
<div class="form-actions">
<button class="cta" type="submit">
Save contact details
Expand Down
92 changes: 92 additions & 0 deletions apps/web/test/list-filter.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';

process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused';

const { LeaguePage, SportPage, SportsIndex, Following } = await import('../src/views/pages.jsx');
const { Feeds } = await import('../src/views/feeds.jsx');
const html = async (node) => String(await node.toString());

/*
* The matcher lives in public/app.js, a plain browser script with no exports, so
* the two pure helpers are pulled out of its source and evaluated on their own.
*/
const appSrc = readFileSync(new URL('../public/app.js', import.meta.url).pathname, 'utf8');
const grab = (name) => {
const start = appSrc.indexOf(`function ${name}(`);
const end = appSrc.indexOf('\n}\n', start);
return appSrc.slice(start, end + 2);
};
const { listFilterWords, listFilterMatches } = new Function(
`${grab('listFilterWords')}\n${grab('listFilterMatches')}\nreturn { listFilterWords, listFilterMatches };`,
)();
const matches = (text, q) => listFilterMatches(text, listFilterWords(q));

describe('list filter matcher', () => {
test('every word, any order, any case', () => {
expect(matches('Manchester City', 'city man')).toBe(true);
expect(matches('Manchester City', 'MAN')).toBe(true);
expect(matches('Manchester City', 'CiTy MANCHESTER')).toBe(true);
expect(matches('Manchester City', 'city united')).toBe(false);
expect(matches('English Premier League', 'league prem')).toBe(true);
});
test('an empty query keeps every row', () => {
expect(matches('Anything', '')).toBe(true);
expect(matches('Anything', ' ')).toBe(true);
});
});

describe('filter boxes on every league and team list', () => {
const user = { id: 'u1', email: 'a@example.test' };
const team = { id: 't1', slug: 'man-city', display_name: 'Manchester City', upcoming: 2 };
const league = { id: 'l1', slug: 'eng.1', name: 'English Premier League', sport: 'soccer' };

test('a league page filters its teams', async () => {
const out = await html(
<LeaguePage user={user} league={league} teams={[team]} events={[]} following={false} />,
);
expect(out).toContain('data-list-filter="teams-list"');
expect(out).toContain('id="teams-list"');
expect(out).toContain('data-filter="Manchester City"');
});

test('a sport page filters its leagues', async () => {
const out = await html(<SportPage user={user} sport="soccer" leagues={[league]} />);
expect(out).toContain('data-list-filter="leagues-list"');
expect(out).toContain('data-filter="English Premier League"');
});

test('the sports index filters its sports', async () => {
const out = await html(<SportsIndex user={null} sports={[{ sport: 'soccer', leagues: 3 }]} />);
expect(out).toContain('data-list-filter="sports-list"');
expect(out).toContain('id="sports-list"');
});

test('the feeds page filters sports and leagues', async () => {
const out = await html(
<Feeds
user={null}
sports={[{ sport: 'soccer', leagues: 3 }]}
leagues={[{ ...league, upcoming: 4 }]}
/>,
);
expect(out).toContain('data-list-filter="feed-sports"');
expect(out).toContain('data-list-filter="feed-leagues"');
});

test('following filters what you follow', async () => {
const out = await html(
<Following
user={user}
events={[]}
follows={[{ subject_type: 'team', subject_id: 't1', label: 'Manchester City' }]}
/>,
);
expect(out).toContain('data-list-filter="follows-list"');
});

test('the box ships hidden so it never shows without JavaScript', async () => {
const out = await html(<SportPage user={user} sport="soccer" leagues={[league]} />);
expect(out).toMatch(/data-list-filter="leagues-list"[^>]*hidden/);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tipoffwatch.com",
"version": "0.27.0",
"version": "0.28.0",
"private": true,
"type": "module",
"description": "Sports calendar and reminder PWA. Follow any team in the world, get told before they play.",
Expand Down
Loading