<div id="app" />
$color-apprentice: #dd0093;
$color-guru: #882d9e;
$color-master: #294ddb;
$color-enlightened: #0093dd;

$color-kanji: #f100a1;
$color-vocabulary: #a100f1;

$spin-time: 1.25s;
$color-cycle-time: 1.25s;

@keyframes spin {
	0% { transform: rotateZ(0deg); }
	100% { transform: rotateZ(360deg); }
}

@keyframes spin-double {
	0% { transform: rotateZ(0deg); }
	50% { transform: rotateZ(180deg); }
	100% { transform: rotateZ(360deg); }
}

@keyframes color-cycle {
	0%, 100% {
		border-color: rgba($color-apprentice, 0.2);
		border-top-color: $color-apprentice;
	}
	
	25% {
		border-color: rgba($color-guru, 0.2);
		border-top-color: $color-guru;
	}
	
	50% {
		border-color: rgba($color-master, 0.2);
		border-top-color: $color-master;
	}
	
	75% {
		border-color: rgba($color-enlightened, 0.2);
		border-top-color: $color-enlightened;
	}
}

@keyframes pulse {
	0%, 100% {
		opacity: 1;
		text-shadow: 0 0 0 rgba(black, 0.2);
	}
  50% {
    opacity: 0.6;
		text-shadow: 0 0 1px rgba(black, 0.8);
  }
}

.loading {
	border-radius: 50%;
	width: 100%;
	height: 100%;
	border-width: 0.15em;
	border-style: solid;
	animation: spin $spin-time infinite linear,
		color-cycle ($color-cycle-time * 4) infinite cubic-bezier(1, 0, 0, 1) ($color-cycle-time * -0.25);
}

.loading-outer {
	display: inline-block;
	font-size: 80px;
	width: 1em;
	height: 1em;
	animation: spin-double ($color-cycle-time * 2) infinite ease;
}

.loading-kanji .heading {
	color: $color-kanji;	
}

.loading-vocabulary .heading {
	color: $color-vocabulary;
}

.pulse {
	animation: pulse 1.5s ease-in-out infinite;
}

.table tr:hover {
	background-color: transparent;
}

.table th, .table td {
	text-align: center;
}

.table td {
	vertical-align: middle;
	padding: 0.5rem 0.75rem;
}

.table td.item {
  background-repeat: repeat-x;
  font-size: 24px;
	
	a {
		color: white;
		display: block;
    margin: -0.5rem -0.75rem;
    padding: 0.5rem 0.75rem;
	}
	
	&.item-vocabulary {
		background-color: $color-vocabulary;
		
		&:hover {
			background-color: lighten($color-vocabulary, 10%);
		}
	}
	
	&.item-kanji {
		background-color: $color-kanji;
		
		&:hover {
			background-color: lighten($color-kanji, 10%);
		}
	}
}

.sort-column {
	cursor: pointer;
	
	&:hover {
		background-color: #fafafa;
	}
}

.srs {
  color: #fff;

	&.srs-apprentice {
		background-color: $color-apprentice;
	}

	&.srs-guru {
		background-color: $color-guru;
	}

	&.srs-master {
		background-color: $color-master;
	}

	&.srs-enlightened {
		background-color: $color-enlightened;
	}
}
View Compiled
class App extends React.PureComponent {
	constructor(props) {
		super(props);
		this.state = {
			apiKey: props.apiKey,
			kanji: props.kanji,
			vocabulary: props.vocabulary
		};
		this.handleApiEntry = this.handleApiEntry.bind(this);
		this.loadData = this.loadData.bind(this);
		this.resetData = this.resetData.bind(this);
	}

	render() {
		const { kanji, vocabulary, apiKey } = this.state;
		return (
			<div>
				<header>
					<nav className="navbar">
						<div className="navbar-brand">
							<span className="navbar-item"><strong>WaniKani Leech Detector</strong></span>
							<a className="navbar-item" href="javascript:void(0)" onClick={this.resetData}>Reset Data</a>
						</div>
					</nav>
				</header>
				<main>
					{
						(kanji && vocabulary) ? <Main kanji={kanji} vocabulary={vocabulary} /> :
						apiKey ? <Loading kanji={kanji == null} vocabulary={vocabulary == null} /> :
						<ApiEntry onEnter={this.handleApiEntry} />
					}
				</main>
				<footer className="footer">
					<div className="container">
						<div className="content has-text-centered">
							<p>
								<strong>WaniKani Leech Detector</strong> by <a href="http://github.com/smrq" target="_blank">smrq</a>. The source code is licensed <a href="http://opensource.org/licenses/mit-license.php" target="_blank">MIT</a>.
							</p>
							<p>
								Forked from <a href="https://community.wanikani.com/t/Leech-Detector/18227" target="_blank">Leech Detector</a> by <a href="https://community.wanikani.com/u/StellaTerra" target="_blank">StellaTerra</a>.
							</p>
						</div>
					</div>
				</footer>
			</div>
		);
	}

	componentDidMount() {
		if (this.state.apiKey && !(this.state.kanji && this.state.vocabulary)) {
			this.loadData();
		}
	}

	handleApiEntry(apiKey) {
		storeLocalData('wanikani_leech_apikey', apiKey);
		this.setState({ apiKey }, this.loadData);
	}

	loadData() {
		const { apiKey } = this.state;
		fetchResource(apiKey, 'vocabulary', 1, data => {
			const vocabulary = scoreData('vocabulary', data.general);
			storeLocalData('wanikani_leech_vocabulary', vocabulary, 1000*60*5);
			this.setState({ vocabulary });
		});
		fetchResource(apiKey, 'kanji', 1, data => {
			const kanji = scoreData('kanji', data);
			storeLocalData('wanikani_leech_kanji', kanji, 1000*60*5);
			this.setState({ kanji });
		});
	}

	resetData() {
		localStorage.removeItem('wanikani_leech_vocabulary');
		localStorage.removeItem('wanikani_leech_kanji');
		localStorage.removeItem('wanikani_leech_apikey');
		this.setState({
			apiKey: null,
			kanji: null,
			vocabulary: null
		});
	}
}

class ApiEntry extends React.PureComponent {
	constructor(props) {
		super(props);
		this.handleSubmit = this.handleSubmit.bind(this);
	}

	render() {
		return (
			<section className="section is-medium">
				<div className="container">
					<h1 className="title">WaniKani Leech Detector</h1>
					<h2 className="subtitle">Get started by entering your API Version 1 Key.</h2>
					<form onSubmit={this.handleSubmit}>
						<div className="columns">
							<div className="column is-half">
								<div className="field" >
									<div className="field has-addons">
										<div className="control is-expanded">
											<input className="input" type="text" name="apiKey" placeholder="546573744b6579506c7349676e6f7265" />
										</div>
										<div className="control">
											<button className="button is-primary" type="submit">Submit</button>
										</div>
									</div>
									<p className="help">You can find your API key in your <a href="https://www.wanikani.com/settings/account#public-api-key" target="_blank">WaniKani Account Settings</a>.</p>
								</div>
							</div>
						</div>
					</form>
				</div>
			</section>
		);
	}

	handleSubmit(event) {
		event.preventDefault();
		this.props.onEnter(event.currentTarget.apiKey.value);
	}
}

function Loading(props) {
	const { kanji, vocabulary } = props;
	return (
		<section className="section is-medium">
			<div className="container has-text-centered">
				<nav className="level">
					<div className="level-item loading-kanji">
						<div>
							<p className="heading">Kanji</p>
							<p className={kanji ? "title pulse" : "title"}>{kanji ? 'Loading' : 'Done'}</p>
						</div>
					</div>
					<div className="level-item">
						<div className="loading-outer">
							<div className="loading" />
						</div>
					</div>
					<div className="level-item loading-vocabulary">
						<div>
							<p className="heading">Vocabulary</p>
							<p className={vocabulary ? "title pulse" : "title"}>{vocabulary ? 'Loading' : 'Done'}</p>
						</div>
					</div>
				</nav>
			</div>
		</section>
	);
}

class Main extends React.PureComponent {
	constructor(props) {
		super(props);
		this.state = {
			sort: 'score',
			sortDescending: true,
			count: 50,
			includeBurned: false
		};
		this.handleSortSrs = this.handleSortSrs.bind(this);
		this.handleSortWrongCount = this.handleSortWrongCount.bind(this);
		this.handleSortScore = this.handleSortScore.bind(this);
		this.handleSortStreak = this.handleSortStreak.bind(this);
		this.handleSortBy = this.handleSortBy.bind(this);
		this.handleShowMore = this.handleShowMore.bind(this);
		this.handleIncludeBurnedChange = this.handleIncludeBurnedChange.bind(this);
	}

	render() {
		const { kanji, vocabulary } = this.props;
		const { sort, sortDescending, count, includeBurned } = this.state;

		let items = [...kanji, ...vocabulary];
		if (!includeBurned) {
			items = items.filter(item => !item.burned);
		}
		sortItems(items, sort, sortDescending);
		const limitedItems = items.slice(0, count);

		return (
			<section className="section">
				<div className="container">
					<p>
						<label>
							<input type="checkbox" checked={includeBurned} onChange={this.handleIncludeBurnedChange} /> Include burned items
						</label>
					</p>
					<table className="table">
						<thead>
							<tr>
								<th>Item</th>
								<th>Type</th>
								<th className='sort-column' onClick={this.handleSortSrs}>SRS</th>
								<th className='sort-column' onClick={this.handleSortWrongCount}>Wrong</th>
								<th className='sort-column' onClick={this.handleSortStreak}>Streak</th>
								<th className='sort-column' onClick={this.handleSortScore}>Score</th>
							</tr>
						</thead>
						<tbody>
							{limitedItems.map(item => (
								<tr>
									<td className={`item item-${item.itemType}`}>
										<a href={`https://www.wanikani.com/${item.itemType}/${item.name}`} target="_blank">
											{item.name}
										</a>
									</td>
									<td>{item.type}</td>
									<td className={`srs srs-${srsLevelName(item.srs)}`}>{item.srs}</td>
									<td className="wrong-count">{item.wrongCount}</td>
									<td className="streak">{item.streak}</td>
									<td className="score">{item.score.toFixed(1)}</td>
								</tr>
							))}
						</tbody>
					</table>
					{items.length > limitedItems.length && (
						<p>
							<a href="javascript:void(0)" onClick={this.handleShowMore}>Show more</a>
						</p>
					)}
				</div>
			</section>
		);
	}

	handleSortSrs() {
		this.handleSortBy('srs');
	}

	handleSortWrongCount() {
		this.handleSortBy('wrongCount');
	}

	handleSortScore() {
		this.handleSortBy('score');
	}
	
	handleSortStreak() {
		this.handleSortBy('streak');
	}
	
	handleSortBy(sort) {
		this.setState(state => ({
			sort,
			sortDescending: state.sort === sort ? !state.sortDescending : true,
			count: 50
		}));
	}
	
	handleShowMore() {
		this.setState(state => ({ count: state.count + 50 }));
	}
	
	handleIncludeBurnedChange() {
		this.setState(state => ({ includeBurned: !state.includeBurned }));
	}
}

function fetchResource(apiKey, resource, failDelay, callback) {
	fetch(`https://www.wanikani.com/api/user/${apiKey}/${resource}`)
		.then(checkResponse)
		.then(parseJson)
		.then(unpackJson)
		.then(callback)
		.catch(error => {
			console.error(error);
			console.warn(`Failed to fetch ${resource}, retrying in ${failDelay} seconds.`);
			setTimeout(
				() => fetchResource(apiKey, resource, failDelay * 2, callback),
				failDelay * 1000);
		});
}

function checkResponse(response) {
	if (response.ok) {
		return response;
	}
	throw new Error('Network response failed.');
}

function parseJson(response) {
	return response.json();
}

function unpackJson(json) {
	if (json.error) {
		throw new Error(json.error);
	}
	return json.requested_information;
}

function scoreData(itemType, data) {
	const result = [];
	data
		.filter(item => item.user_specific != null)
		.forEach(item => {
			const name = item.character;
			const details = item.user_specific;

			if (details.meaning_incorrect > 0) {
				const score = details.meaning_incorrect / details.meaning_current_streak;
				result.push({
					itemType,
					name,
					type: 'Meaning',
					srs: details.srs_numeric,
					burned: details.burned,
					wrongCount: details.meaning_incorrect,
					streak: details.meaning_current_streak,
					score,
				});
			}

			if (details.reading_incorrect > 0) {
				const score = details.reading_incorrect / details.reading_current_streak;
				result.push({
					itemType,
					name,
					type: 'Reading',
					srs: details.srs_numeric,
					burned: details.burned,
					wrongCount: details.reading_incorrect,
					streak: details.reading_current_streak,
					score
				});
			}
		});
	return result;
}

function sortItems(items, sort, sortDescending) {
	const sortFunction = getSortFunction(sort);
	const sortDirectionFunction = sortDescending ? descending : ascending;
	if (sortFunction) {
		items.sort(sortDirectionFunction(sortFunction));
	}
}

function getSortFunction(sort) {
	switch (sort) {
		case 'score':
			return (a, b) => byScore(a, b) || bySrs(a, b);
		case 'srs':
			return (a, b) => bySrs(a, b) || byScore(a, b);
		case 'streak':
			return (a, b) => byStreak(b, a) || byScore(a, b) || bySrs(a, b);
		case 'wrongCount':
			return (a, b) => byWrongCount(a, b) || byScore(a, b) || bySrs(a, b);
		default:
			return;
	}
}

function descending(sort) {
	return sort;
}

function ascending(sort) {
	return (a, b) => sort(b, a);
}

function byScore(a, b) {
	const aScore = a.wrongCount / a.streak;
	const bScore = b.wrongCount / b.streak;
	return bScore - aScore;
}

function byWrongCount(a, b) {
	return b.wrongCount - a.wrongCount;
}

function bySrs(a, b) {
	return b.srs - a.srs;
}

function byStreak(a, b) {
	return b.streak - a.streak;
}

function srsLevelName(srsNumber) {
	switch (srsNumber) {
		case 9:
			return 'burned';
		case 8:
			return 'enlightened';
		case 7:
			return 'master';
		case 6:
		case 5:
			return 'guru';
		case 4:
		case 3:
		case 2:
		case 1:
			return 'apprentice';
		default:
			throw new Error(`Invalid SRS level: ${srsNumber}`);
	}
}

function storeLocalData(key, data, expiration) {
	localStorage.setItem(key, JSON.stringify({
		data,
		expires: expiration && (Date.now() + expiration)
	}));
}

function retrieveLocalData(key) {
	let stored = localStorage.getItem(key);
	if (!stored) {
		return null;
	}

	stored = JSON.parse(stored);
	if (stored.expires && Date.now() > stored.expires) {
		localStorage.removeItem(key);
		return null;
	}

	return stored.data;
}

const apiKey = retrieveLocalData('wanikani_leech_apikey');
const kanji = retrieveLocalData('wanikani_leech_kanji');
const vocabulary = retrieveLocalData('wanikani_leech_vocabulary');

ReactDOM.render(
	<App apiKey={apiKey} kanji={kanji} vocabulary={vocabulary} />,
	document.getElementById('app'));
View Compiled
Run Pen

External CSS

  1. https://npmcdn.com/bulma@0.5.0/css/bulma.css

External JavaScript

  1. https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/qs/6.5.0/qs.js
  3. https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react.min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/react/15.6.1/react-dom.min.js