diff --git a/src/components/Modals.js b/src/components/Modals.js
index f9bacf3..dbf670d 100644
--- a/src/components/Modals.js
+++ b/src/components/Modals.js
@@ -229,6 +229,10 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
Current Rank:
${state.getRank().current.badge} ${state.getRank().current.name}
+
+ Quartiles Discovered:
+ ${state.getUniqueQuartilesSeenCount?.() || 0} / ${state.getTotalTargetWordsCount?.() || 1352}
+
`;
diff --git a/src/components/WordsDrawer.js b/src/components/WordsDrawer.js
index ddde429..387fb07 100644
--- a/src/components/WordsDrawer.js
+++ b/src/components/WordsDrawer.js
@@ -135,11 +135,20 @@ export function createWordsDrawer({ onRevealAll }) {
` : ''}
`
- : displayWords
+ : `
+ ${
+ currentTab === 'quartiles'
+ ? `
+ ⭐ 4-Tile Quartiles: Showing times seen across boards. Boards prioritize least-seen words.
+
`
+ : ''
+ }
+ ${displayWords
.map(w => {
const isFound = state.foundWords.has(w.word.toLowerCase());
const isQuartile = w.tileCount === 4;
const clue = quartileDetailsMap.get(w.word.toLowerCase());
+ const seenCount = isQuartile ? (state.getQuartileSeenCount?.(w.word) || 0) : 0;
return `
@@ -154,6 +163,11 @@ export function createWordsDrawer({ onRevealAll }) {
: 'Found')
: 'Revealed'
}
+ ${
+ isQuartile
+ ? `Seen ${seenCount}x`
+ : ''
+ }
${(w.tileSegments || []).map(s => `${s.toUpperCase()}`).join('+')}
@@ -164,7 +178,8 @@ export function createWordsDrawer({ onRevealAll }) {
`;
})
- .join('')
+ .join('')}
+ `
}
diff --git a/src/engine/generator.js b/src/engine/generator.js
index a0cc8f5..078c958 100644
--- a/src/engine/generator.js
+++ b/src/engine/generator.js
@@ -2,6 +2,11 @@ import { TARGET_WORDS_POOL } from '../data/puzzles.js';
import { solveBoard } from './solver.js';
import { DICTIONARY_SET } from '../data/lexicon.js';
import { createSeededRandom } from './random.js';
+import {
+ getQuartileSeenCount,
+ recordQuartilesSeen,
+ selectTargetWordsWithSeenFilter
+} from './quartile-tracker.js';
export { createSeededRandom };
@@ -97,19 +102,12 @@ function pickWords(pool, { enforceVariety = true } = {}) {
return null;
}
-function selectWords(candidatePool, rng) {
- const attempts = [
- () => pickWords(weightedShuffle(candidatePool, rng)),
- () => pickWords(weightedShuffle(TARGET_WORDS_POOL, rng)),
- () => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
- () => pickWords(shuffle(TARGET_WORDS_POOL, rng), { enforceVariety: false })
- ];
- for (const attempt of attempts) {
- const result = attempt();
- if (result) return result;
- }
- // Fallback if extreme constraints somehow failed
- return shuffle(TARGET_WORDS_POOL, rng).slice(0, 5);
+function selectWords(candidatePool, rng, getCountFn = getQuartileSeenCount) {
+ return selectTargetWordsWithSeenFilter(candidatePool, rng, getCountFn, {
+ pickWordsFn: pickWords,
+ weightedShuffleFn: weightedShuffle,
+ shuffleFn: shuffle
+ });
}
function buildPuzzle(selectedWords, meta) {
@@ -144,50 +142,28 @@ function themeLabel(selectedWords) {
* - All tiles are at least 2 characters (never 1 letter)
* - Variety: at most one -tion/-sion answer, two 11+ letter answers, and one
* tile that is a short word by itself (e.g. "in", "per")
- * - Anti-repetition: tracks recently used words in localStorage across games
- * (skipped when a seed is given, so seeded puzzles stay deterministic)
+ * - Fair distribution: prioritizes words with least views (0 views before 1 view)
+ * and increments seen counts when new boards are created
*/
export function generateRandomPuzzle(seed) {
const seeded = seed !== undefined && seed !== null;
const rng = seeded ? createSeededRandom(seed) : Math.random;
- // Load recently used target words to prevent repetition across games
- let recentWords = [];
+ const selectedWords = selectWords(
+ TARGET_WORDS_POOL,
+ rng,
+ seeded ? () => 0 : getQuartileSeenCount
+ );
+
+ const puzzleId = 'custom-' + Date.now();
+
+ // If live game (unseeded), track newly seen quartiles
if (!seeded) {
- try {
- const rawRecent = localStorage.getItem('quartiles_recent_target_words');
- if (rawRecent) {
- recentWords = JSON.parse(rawRecent);
- if (!Array.isArray(recentWords)) recentWords = [];
- }
- } catch (e) {
- recentWords = [];
- }
- }
- const recentSet = new Set(recentWords.map(w => w.toLowerCase()));
-
- // Filter pool to prioritize words not played recently
- const nonRecentPool = TARGET_WORDS_POOL.filter(w => !recentSet.has(w.word.toLowerCase()));
- // If non-recent pool is too small, use full pool
- const poolCandidate = nonRecentPool.length >= 25 ? nonRecentPool : [...TARGET_WORDS_POOL];
-
- const selectedWords = selectWords(poolCandidate, rng);
-
- // Save selected words into recent words history (keep up to last 50 words)
- if (!seeded) {
- try {
- const updatedRecent = [
- ...selectedWords.map(w => w.word.toLowerCase()),
- ...recentWords.filter(w => !selectedWords.some(sw => sw.word.toLowerCase() === w))
- ].slice(0, 50);
- localStorage.setItem('quartiles_recent_target_words', JSON.stringify(updatedRecent));
- } catch (e) {
- // Non-fatal if localStorage unavailable
- }
+ recordQuartilesSeen(selectedWords.map(w => w.word), puzzleId);
}
return buildPuzzle(selectedWords, {
- id: 'custom-' + Date.now(),
+ id: puzzleId,
date: new Date().toISOString().slice(0, 10),
title: `Unlimited Puzzle #${Math.floor(rng() * 9000 + 1000)}`,
theme: themeLabel(selectedWords),
@@ -195,9 +171,62 @@ export function generateRandomPuzzle(seed) {
});
}
+const DAILY_EPOCH = '2026-01-01';
+const dailySelectionsCache = new Map();
+const dailySequenceSeenCounts = new Map();
+let lastComputedDailyDate = null;
+
+function computeDailySelectionsUpTo(targetDateStr) {
+ if (dailySelectionsCache.has(targetDateStr)) {
+ return dailySelectionsCache.get(targetDateStr);
+ }
+
+ // Handle dates before epoch
+ if (targetDateStr < DAILY_EPOCH) {
+ const rng = createSeededRandom(`quartiles-daily-${targetDateStr}`);
+ const selected = selectWords(TARGET_WORDS_POOL, rng, () => 0);
+ dailySelectionsCache.set(targetDateStr, selected);
+ return selected;
+ }
+
+ let cur;
+ if (!lastComputedDailyDate) {
+ cur = new Date(DAILY_EPOCH + 'T00:00:00');
+ } else {
+ cur = new Date(lastComputedDailyDate + 'T00:00:00');
+ cur.setDate(cur.getDate() + 1);
+ }
+
+ const target = new Date(targetDateStr + 'T00:00:00');
+
+ while (cur <= target) {
+ const y = cur.getFullYear();
+ const m = String(cur.getMonth() + 1).padStart(2, '0');
+ const d = String(cur.getDate()).padStart(2, '0');
+ const dStr = `${y}-${m}-${d}`;
+
+ if (!dailySelectionsCache.has(dStr)) {
+ const rng = createSeededRandom(`quartiles-daily-${dStr}`);
+ const selected = selectWords(
+ TARGET_WORDS_POOL,
+ rng,
+ (w) => dailySequenceSeenCounts.get(w.toLowerCase()) || 0
+ );
+ dailySelectionsCache.set(dStr, selected);
+ for (const item of selected) {
+ const key = item.word.toLowerCase();
+ dailySequenceSeenCounts.set(key, (dailySequenceSeenCounts.get(key) || 0) + 1);
+ }
+ lastComputedDailyDate = dStr;
+ }
+ cur.setDate(cur.getDate() + 1);
+ }
+
+ return dailySelectionsCache.get(targetDateStr);
+}
+
function selectDaily(dateStr) {
- const rng = createSeededRandom(`quartiles-daily-${dateStr}`);
- const selectedWords = selectWords(TARGET_WORDS_POOL, rng);
+ const selectedWords = computeDailySelectionsUpTo(dateStr);
const [y, m, d] = dateStr.split('-').map(Number);
const label = new Date(y, m - 1, d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const meta = {
diff --git a/src/engine/quartile-tracker.js b/src/engine/quartile-tracker.js
new file mode 100644
index 0000000..81a49a2
--- /dev/null
+++ b/src/engine/quartile-tracker.js
@@ -0,0 +1,258 @@
+import { TARGET_WORDS_POOL } from '../data/puzzles.js';
+
+const STORAGE_KEY_SEEN_COUNTS = 'quartiles_quartile_seen_counts';
+const STORAGE_KEY_RECORDED_PUZZLES = 'quartiles_recorded_puzzle_ids';
+
+let inMemoryCounts = null;
+let inMemoryRecordedPuzzles = null;
+
+function isStorageAvailable() {
+ try {
+ return typeof localStorage !== 'undefined' && localStorage !== null;
+ } catch (e) {
+ return false;
+ }
+}
+
+function loadCounts() {
+ if (inMemoryCounts) return inMemoryCounts;
+ inMemoryCounts = new Map();
+ if (isStorageAvailable()) {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_SEEN_COUNTS);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') {
+ for (const [k, v] of Object.entries(parsed)) {
+ if (typeof v === 'number' && v > 0) {
+ inMemoryCounts.set(k.toLowerCase(), v);
+ }
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to load quartile seen counts:', e);
+ }
+ }
+ return inMemoryCounts;
+}
+
+function saveCounts() {
+ if (!inMemoryCounts || !isStorageAvailable()) return;
+ try {
+ const obj = {};
+ for (const [k, v] of inMemoryCounts.entries()) {
+ obj[k] = v;
+ }
+ localStorage.setItem(STORAGE_KEY_SEEN_COUNTS, JSON.stringify(obj));
+ } catch (e) {
+ console.warn('Failed to save quartile seen counts:', e);
+ }
+}
+
+function loadRecordedPuzzles() {
+ if (inMemoryRecordedPuzzles) return inMemoryRecordedPuzzles;
+ inMemoryRecordedPuzzles = new Set();
+ if (isStorageAvailable()) {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_RECORDED_PUZZLES);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ for (const id of parsed) {
+ inMemoryRecordedPuzzles.add(String(id));
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to load recorded puzzles:', e);
+ }
+ }
+ return inMemoryRecordedPuzzles;
+}
+
+function saveRecordedPuzzles() {
+ if (!inMemoryRecordedPuzzles || !isStorageAvailable()) return;
+ try {
+ const arr = Array.from(inMemoryRecordedPuzzles);
+ localStorage.setItem(STORAGE_KEY_RECORDED_PUZZLES, JSON.stringify(arr.slice(-500)));
+ } catch (e) {
+ console.warn('Failed to save recorded puzzles:', e);
+ }
+}
+
+/**
+ * Returns how many times a given 4-tile quartile word has been seen
+ */
+export function getQuartileSeenCount(word) {
+ if (!word) return 0;
+ const counts = loadCounts();
+ return counts.get(word.toLowerCase().trim()) || 0;
+}
+
+/**
+ * Returns all quartile seen counts as a plain object { word: count }
+ */
+export function getAllQuartileSeenCounts() {
+ const counts = loadCounts();
+ const obj = {};
+ for (const [k, v] of counts.entries()) {
+ obj[k] = v;
+ }
+ return obj;
+}
+
+/**
+ * Returns count of distinct quartile words seen at least once
+ */
+export function getUniqueQuartilesSeenCount() {
+ const counts = loadCounts();
+ return counts.size;
+}
+
+/**
+ * Returns total number of target quartile words in the pool
+ */
+export function getTotalTargetWordsCount() {
+ return TARGET_WORDS_POOL.length;
+}
+
+/**
+ * Checks if a puzzle ID has already had its quartiles recorded
+ */
+export function hasRecordedPuzzle(puzzleId) {
+ if (!puzzleId) return false;
+ const set = loadRecordedPuzzles();
+ return set.has(String(puzzleId));
+}
+
+/**
+ * Increments seen count for the given quartile words.
+ * If puzzleId is provided, guarantees each puzzle is recorded at most once.
+ */
+export function recordQuartilesSeen(words, puzzleId = null) {
+ if (!Array.isArray(words) || words.length === 0) return false;
+
+ if (puzzleId) {
+ const recorded = loadRecordedPuzzles();
+ if (recorded.has(String(puzzleId))) {
+ return false; // Already recorded for this puzzle instance
+ }
+ recorded.add(String(puzzleId));
+ saveRecordedPuzzles();
+ }
+
+ const counts = loadCounts();
+ for (const w of words) {
+ if (!w) continue;
+ const key = (typeof w === 'string' ? w : w.word || '').toLowerCase().trim();
+ if (!key) continue;
+ counts.set(key, (counts.get(key) || 0) + 1);
+ }
+ saveCounts();
+ return true;
+}
+
+/**
+ * Reset all tracked quartile view counts and recorded puzzles
+ */
+export function resetQuartileSeenCounts() {
+ inMemoryCounts = new Map();
+ inMemoryRecordedPuzzles = new Set();
+ if (isStorageAvailable()) {
+ try {
+ localStorage.removeItem(STORAGE_KEY_SEEN_COUNTS);
+ localStorage.removeItem(STORAGE_KEY_RECORDED_PUZZLES);
+ } catch (e) {}
+ }
+}
+
+/**
+ * Core selection algorithm:
+ * "Don't include words that have been seen more than the rest of the words.
+ * So if there's still words with 0 views show those before any that have seen 1 time.
+ * If there's not enough words left not seen more than the rest and you are making a new board
+ * then use the ones seen and increment those being seen.
+ * Basically, seen less than the other words == include in board, seen more == exclude from boards"
+ */
+export function selectTargetWordsWithSeenFilter(
+ pool,
+ rng,
+ getCountFn,
+ { pickWordsFn, weightedShuffleFn, shuffleFn } = {}
+) {
+ const getCount = getCountFn || getQuartileSeenCount;
+ if (!pool || pool.length === 0) return [];
+ if (pool.length <= 5) return pool;
+
+ // 1. Find minimum seen count in the pool
+ let minCount = Infinity;
+ for (const item of pool) {
+ const c = getCount(item.word);
+ if (c < minCount) minCount = c;
+ }
+
+ // 2. Partition candidate pool into tiers by seen count
+ const tierMin = [];
+ const tierNext = [];
+ const tierRest = [];
+
+ for (const item of pool) {
+ const c = getCount(item.word);
+ if (c === minCount) {
+ tierMin.push(item);
+ } else if (c === minCount + 1) {
+ tierNext.push(item);
+ } else {
+ tierRest.push(item);
+ }
+ }
+
+ const shuffle =
+ shuffleFn ||
+ (arr => {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+ });
+
+ const weightShuffle = weightedShuffleFn || shuffle;
+ const pick = pickWordsFn;
+
+ // Attempt 1: Strict minSeen only (0 views before 1 view)
+ let result = pick(weightShuffle(tierMin, rng));
+ if (result) return result;
+
+ // Attempt 2: If minSeen alone cannot form 5 non-clashing words,
+ // greedily pick from tierMin first, filling only remaining slots from tierNext
+ if (tierNext.length > 0) {
+ result = pick([...weightShuffle(tierMin, rng), ...weightShuffle(tierNext, rng)]);
+ if (result) return result;
+
+ result = pick([...shuffle(tierMin, rng), ...shuffle(tierNext, rng)]);
+ if (result) return result;
+
+ result = pick([...shuffle(tierMin, rng), ...shuffle(tierNext, rng)], { enforceVariety: false });
+ if (result) return result;
+ }
+
+ // Attempt 3: Relax variety limits on tierMin
+ result = pick(shuffle(tierMin, rng), { enforceVariety: false });
+ if (result) return result;
+
+ // Attempt 4: Expand to tierRest if strictly needed
+ if (tierRest.length > 0) {
+ result = pick(
+ [...shuffle(tierMin, rng), ...shuffle(tierNext, rng), ...shuffle(tierRest, rng)],
+ { enforceVariety: false }
+ );
+ if (result) return result;
+ }
+
+ // Fallback fallback
+ return shuffle(pool, rng).slice(0, 5);
+}
+
diff --git a/src/state/game-state.js b/src/state/game-state.js
index cb4f706..f608ba1 100644
--- a/src/state/game-state.js
+++ b/src/state/game-state.js
@@ -2,6 +2,12 @@ import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '..
import { isAcceptedWord } from '../data/lexicon.js';
import { resolveBuiltInPuzzle, isBuiltInPuzzleId, getDailyPuzzle, toDateString } from '../engine/daily.js';
import { trackGameStart, trackGameFinish } from '../engine/analytics.js';
+import {
+ recordQuartilesSeen,
+ getQuartileSeenCount,
+ getUniqueQuartilesSeenCount,
+ getTotalTargetWordsCount
+} from '../engine/quartile-tracker.js';
// Identifies the exact board, so saves from an older version of a puzzle id are ignored
function boardSignature(puzzle) {
@@ -81,6 +87,12 @@ class GameStateManager {
// Organize board tiles according to lock state
this.reorganizeBoard();
+ // Track quartiles seen for this puzzle (at most once per puzzle ID)
+ const quartilesToRecord = puzzle.targetQuartiles || puzzle.quartileDetails?.map(q => q.word) || [];
+ if (quartilesToRecord.length > 0) {
+ recordQuartilesSeen(quartilesToRecord, puzzle.id);
+ }
+
// Track game start in analytics
const rank = this.getRank();
const isCompleted = rank.isPerfect || this.isRevealed;
@@ -665,6 +677,19 @@ class GameStateManager {
return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 };
}
}
+
+ // Quartile view tracking helpers
+ getQuartileSeenCount(word) {
+ return getQuartileSeenCount(word);
+ }
+
+ getUniqueQuartilesSeenCount() {
+ return getUniqueQuartilesSeenCount();
+ }
+
+ getTotalTargetWordsCount() {
+ return getTotalTargetWordsCount();
+ }
}
export const gameState = new GameStateManager();
diff --git a/src/styles/components.css b/src/styles/components.css
index 811068f..9149d2c 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -596,6 +596,27 @@
color: var(--accent-quartile);
}
+.status-chip.quartile-seen {
+ background: rgba(59, 130, 246, 0.12);
+ color: var(--accent-blue);
+ border: 1px solid rgba(59, 130, 246, 0.28);
+}
+
+.quartile-tab-banner {
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(59, 130, 246, 0.06));
+ border: 1px solid rgba(245, 158, 11, 0.25);
+ border-radius: var(--radius-md);
+ padding: 8px 12px;
+ margin-bottom: 12px;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.quartile-tab-banner strong {
+ color: var(--text-primary);
+}
+
.quartile-clue {
font-size: 0.75rem;
color: var(--text-secondary);
diff --git a/src/version.json b/src/version.json
index e025fe3..bcf6b6e 100644
--- a/src/version.json
+++ b/src/version.json
@@ -1,3 +1,3 @@
{
- "version": "1.0.6"
+ "version": "1.0.7"
}