@scrabble-solver/scrabble-solver 2.18.1 → 2.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +14 -14
  3. package/.next/cache/.previewinfo +1 -1
  4. package/.next/cache/.rscinfo +1 -1
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/webpack/client-production/0.pack +0 -0
  7. package/.next/cache/webpack/client-production/index.pack +0 -0
  8. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  9. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  10. package/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
  11. package/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/.next/cache/webpack/server-production/index.pack.old +0 -0
  14. package/.next/diagnostics/framework.json +1 -1
  15. package/.next/dynamic-css-manifest.json +2 -2
  16. package/.next/prerender-manifest.json +4 -4
  17. package/.next/react-loadable-manifest.json +37 -37
  18. package/.next/routes-manifest.json +1 -1
  19. package/.next/server/chunks/309.js +1 -1
  20. package/.next/server/chunks/344.js +1 -1
  21. package/.next/server/chunks/371.js +1 -1
  22. package/.next/server/chunks/392.js +1 -1
  23. package/.next/server/chunks/465.js +1 -1
  24. package/.next/server/chunks/546.js +1 -1
  25. package/.next/server/chunks/641.js +1 -1
  26. package/.next/server/chunks/67.js +1 -0
  27. package/.next/server/chunks/712.js +1 -1
  28. package/.next/server/chunks/812.js +1 -1
  29. package/.next/server/chunks/846.js +1 -1
  30. package/.next/server/chunks/886.js +1 -1
  31. package/.next/server/chunks/942.js +1 -0
  32. package/.next/server/chunks/953.js +1 -1
  33. package/.next/server/chunks/961.js +1 -1
  34. package/.next/server/dynamic-css-manifest.js +1 -1
  35. package/.next/server/middleware-build-manifest.js +1 -1
  36. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  37. package/.next/server/pages/404.html +3 -3
  38. package/.next/server/pages/500.html +3 -3
  39. package/.next/server/pages/_app.js +1 -1
  40. package/.next/server/pages/_app.js.nft.json +1 -1
  41. package/.next/server/pages/_document.js +2 -2
  42. package/.next/server/pages/_document.js.nft.json +1 -1
  43. package/.next/server/pages/_error.js +1 -1
  44. package/.next/server/pages/_error.js.nft.json +1 -1
  45. package/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
  46. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  47. package/.next/server/pages/api/dictionary/[locale].js +1 -1
  48. package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  49. package/.next/server/pages/api/solve.js +1 -1
  50. package/.next/server/pages/api/solve.js.nft.json +1 -1
  51. package/.next/server/pages/api/verify.js +1 -1
  52. package/.next/server/pages/api/verify.js.nft.json +1 -1
  53. package/.next/server/pages/index.html +3 -3
  54. package/.next/server/pages/index.js +4 -4
  55. package/.next/server/pages/index.js.nft.json +1 -1
  56. package/.next/server/pages/index.json +1 -1
  57. package/.next/server/pages/not-found.html +3 -3
  58. package/.next/server/pages/not-found.js.nft.json +1 -1
  59. package/.next/server/pages-manifest.json +1 -1
  60. package/.next/static/{5KOWzYqfY1Z2rjrzptr8D → 8WnIUTe-yJvPD6stltvYI}/_buildManifest.js +1 -1
  61. package/.next/static/chunks/100.305bdf9c75b1758f.js +1 -0
  62. package/.next/static/chunks/256.6a20b23a766cfebd.js +1 -0
  63. package/.next/static/chunks/{307.67f52f13e3d60d4e.js → 307.89e6100f58443679.js} +1 -1
  64. package/.next/static/chunks/{363.01eed3233f92b6a4.js → 363.cec65b1f9668a75e.js} +1 -1
  65. package/.next/static/chunks/{422.1559ae6756912ca5.js → 422.3e55b4c94e328701.js} +1 -1
  66. package/.next/static/chunks/{663.d2c9ca36b26695b8.js → 663.bd2c45413fbca518.js} +1 -1
  67. package/.next/static/chunks/693.677c88e0eb1340b5.js +1 -0
  68. package/.next/static/chunks/{820.97b82f751ca49579.js → 820.823e9e885ebb66af.js} +1 -1
  69. package/.next/static/chunks/827.c81b92b383a748c9.js +1 -0
  70. package/.next/static/chunks/{865.a9b563a45848a83c.js → 865.59baba8296c9b116.js} +1 -1
  71. package/.next/static/chunks/912.077603e4f0bc6cca.js +1 -0
  72. package/.next/static/chunks/{939.19b33a3d3c351d73.js → 939.2ad86b50405c7897.js} +1 -1
  73. package/.next/static/chunks/980.7ce17464fc56648a.js +1 -0
  74. package/.next/static/chunks/{main-b92e08f3fc96dd8d.js → main-7e52da35a4beea0b.js} +1 -1
  75. package/.next/static/chunks/pages/_app-e3fe880e24c024ab.js +1 -0
  76. package/.next/static/chunks/pages/index-b073a1686818dd26.js +1 -0
  77. package/.next/static/chunks/webpack-d7138f1ef6b8c9d6.js +1 -0
  78. package/.next/static/css/65bcc9440e067113.css +1 -0
  79. package/.next/static/css/{4dbcacc1122ac6b9.css → 92f85ce358a0a468.css} +1 -1
  80. package/.next/static/css/a691348b788f8bc0.css +1 -0
  81. package/.next/static/css/cd4d7f2458487399.css +1 -0
  82. package/.next/trace +22 -21
  83. package/.next/trace-build +1 -1
  84. package/AGENTS.md +9 -0
  85. package/CLAUDE.md +1 -0
  86. package/package.json +14 -12
  87. package/public/llms.txt +1 -1
  88. package/src/components/Board/Board.tsx +3 -1
  89. package/src/components/Board/BoardPure.tsx +26 -20
  90. package/src/components/Board/components/Cell/Cell.tsx +4 -4
  91. package/src/components/Board/lib.test.ts +106 -0
  92. package/src/components/Board/lib.ts +34 -3
  93. package/src/components/Board/selectors.ts +17 -5
  94. package/src/components/Button/Button.module.scss +1 -1
  95. package/src/components/Modal/Modal.module.scss +0 -4
  96. package/src/components/Results/Header.tsx +30 -5
  97. package/src/components/Results/Result.tsx +45 -55
  98. package/src/components/Results/Results.module.scss +0 -126
  99. package/src/components/Results/Results.module.scss.d.ts +0 -10
  100. package/src/components/Results/Results.tsx +25 -5
  101. package/src/components/Solver/Solver.module.scss +4 -4
  102. package/src/components/Solver/Solver.tsx +5 -3
  103. package/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.module.scss +5 -1
  104. package/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.module.scss.d.ts +1 -0
  105. package/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.tsx +5 -2
  106. package/src/components/Table/Cell.tsx +42 -0
  107. package/src/components/Table/Header.tsx +11 -0
  108. package/src/components/{Results → Table}/HeaderButton.tsx +22 -14
  109. package/src/components/Table/Row.tsx +59 -0
  110. package/src/components/{ResultsInput/ResultsInput.tsx → Table/Search.tsx} +9 -11
  111. package/src/components/Table/Table.module.scss +147 -0
  112. package/src/components/Table/Table.module.scss.d.ts +19 -0
  113. package/src/components/Table/index.ts +5 -0
  114. package/src/components/Tile/Tile.module.scss +2 -1
  115. package/src/hooks/useColumns.ts +7 -5
  116. package/src/hooks/useIsCompactLayout.ts +14 -0
  117. package/src/i18n/languages/english.json +6 -1
  118. package/src/i18n/languages/french.json +6 -1
  119. package/src/i18n/languages/german.json +6 -1
  120. package/src/i18n/languages/persian.json +6 -1
  121. package/src/i18n/languages/polish.json +6 -1
  122. package/src/i18n/languages/romanian.json +6 -1
  123. package/src/i18n/languages/spanish.json +6 -1
  124. package/src/i18n/languages/turkish.json +6 -1
  125. package/src/icons/QuestionSquare.svg +5 -0
  126. package/src/lib/createCoordinatesComparator.ts +23 -0
  127. package/src/lib/createKeyComparator.ts +1 -4
  128. package/src/lib/createSortComparator.ts +22 -0
  129. package/src/lib/getCoordinates.test.ts +21 -0
  130. package/src/lib/getCoordinates.ts +16 -0
  131. package/src/lib/getNextSort.ts +12 -0
  132. package/src/modals/RemainingTilesModal/RemainingTilesModal.tsx +3 -1
  133. package/src/modals/RemainingTilesModal/components/Character/Character.tsx +8 -9
  134. package/src/modals/ResultsModal/ResultsModal.tsx +14 -10
  135. package/src/modals/WordsModal/WordsModal.module.scss +16 -27
  136. package/src/modals/WordsModal/WordsModal.module.scss.d.ts +3 -6
  137. package/src/modals/WordsModal/WordsModal.tsx +56 -42
  138. package/src/modals/WordsModal/components/WordsTable/WordRow.tsx +92 -0
  139. package/src/modals/WordsModal/components/WordsTable/WordsTable.module.scss +69 -0
  140. package/src/modals/WordsModal/components/WordsTable/WordsTable.module.scss.d.ts +16 -0
  141. package/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +137 -0
  142. package/src/modals/WordsModal/components/WordsTable/index.ts +1 -0
  143. package/src/modals/WordsModal/components/index.ts +1 -0
  144. package/src/pages/api/verify.ts +7 -6
  145. package/src/pages/index.tsx +29 -4
  146. package/src/parameters/index.ts +2 -0
  147. package/src/sdk/verify.ts +3 -2
  148. package/src/solver-worker/client.test.ts +10 -3
  149. package/src/solver-worker/index.worker.ts +7 -7
  150. package/src/solver-worker/messages.ts +3 -3
  151. package/src/state/hoveredWord/index.ts +4 -0
  152. package/src/state/hoveredWord/initialState.ts +5 -0
  153. package/src/state/hoveredWord/selectors.ts +5 -0
  154. package/src/state/hoveredWord/slice.ts +18 -0
  155. package/src/state/hoveredWord/types.ts +5 -0
  156. package/src/state/index.ts +1 -0
  157. package/src/state/localStorage.ts +13 -2
  158. package/src/state/results/lib.ts +9 -26
  159. package/src/state/results/selectors.ts +9 -1
  160. package/src/state/results/slice.ts +4 -16
  161. package/src/state/sagas.ts +26 -1
  162. package/src/state/selectors.ts +18 -1
  163. package/src/state/store.ts +2 -0
  164. package/src/state/types.ts +2 -0
  165. package/src/state/verify/initialState.ts +7 -0
  166. package/src/state/verify/lib.test.ts +55 -0
  167. package/src/state/verify/lib.ts +49 -0
  168. package/src/state/verify/selectors.ts +37 -0
  169. package/src/state/verify/slice.ts +13 -2
  170. package/src/state/verify/types.ts +6 -2
  171. package/src/styles/_tokens.scss +2 -0
  172. package/src/styles/mixins.scss +5 -0
  173. package/src/styles/variables.module.scss +1 -0
  174. package/src/styles/variables.module.scss.d.ts +1 -0
  175. package/src/styles/variables.scss +1 -1
  176. package/src/types/index.ts +24 -11
  177. package/tsconfig.tsbuildinfo +1 -1
  178. package/.next/server/chunks/316.js +0 -1
  179. package/.next/server/chunks/916.js +0 -1
  180. package/.next/static/chunks/106.e414e88694add27f.js +0 -1
  181. package/.next/static/chunks/127.125d896a19061d71.js +0 -1
  182. package/.next/static/chunks/18.8b91e137d239e15c.js +0 -1
  183. package/.next/static/chunks/583.7a46807be14c377e.js +0 -1
  184. package/.next/static/chunks/711.ba7b8f900dfd40d0.js +0 -1
  185. package/.next/static/chunks/912.ff8037ba9d4db5c8.js +0 -1
  186. package/.next/static/chunks/pages/_app-2e607dcd4135f340.js +0 -1
  187. package/.next/static/chunks/pages/index-e0087247c995328d.js +0 -1
  188. package/.next/static/chunks/webpack-cfb34d175e081981.js +0 -1
  189. package/.next/static/css/b74cd7de82dabcdd.css +0 -1
  190. package/.next/static/css/c403ce739a9c0d83.css +0 -1
  191. package/.next/static/css/fbfdce70731d0d2b.css +0 -1
  192. package/src/components/Results/Cell.tsx +0 -32
  193. package/src/components/ResultsInput/ResultsInput.module.scss +0 -12
  194. package/src/components/ResultsInput/ResultsInput.module.scss.d.ts +0 -7
  195. package/src/components/ResultsInput/index.ts +0 -1
  196. /package/.next/static/{5KOWzYqfY1Z2rjrzptr8D → 8WnIUTe-yJvPD6stltvYI}/_ssgManifest.js +0 -0
@@ -6,7 +6,7 @@ import { execSync } from 'child_process';
6
6
  import fs from 'fs';
7
7
  import dynamic from 'next/dynamic';
8
8
  import path from 'path';
9
- import { type FunctionComponent, useCallback, useEffect, useState } from 'react';
9
+ import { type FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
10
10
  import { useDispatch } from 'react-redux';
11
11
 
12
12
  import { Logo } from '@/components/Logo';
@@ -14,13 +14,23 @@ import { NavButtons } from '@/components/NavButtons';
14
14
  import { Solver } from '@/components/Solver';
15
15
  import { useDirection } from '@/hooks/useDirection';
16
16
  import { useEffectOnce } from '@/hooks/useEffectOnce';
17
+ import { useIsCompactLayout } from '@/hooks/useIsCompactLayout';
17
18
  import { useLanguage } from '@/hooks/useLanguage';
18
19
  import { useLocalStorage } from '@/hooks/useLocalStorage';
19
20
  import { LOCALE_FEATURES } from '@/i18n/constants';
20
21
  import { schedulePreloadModals } from '@/modals/preload';
21
22
  import { CONFIG_PENDING_CLASS, GITHUB_PROJECT_URL, NPM_PACKAGE_URL, SITE_DESCRIPTION, SITE_URL } from '@/parameters';
22
23
  import { registerServiceWorker } from '@/serviceWorkerManager';
23
- import { initialize, reset, selectConfig, selectIsHydrated, selectLocale, useTypedSelector } from '@/state';
24
+ import {
25
+ hoveredWordSlice,
26
+ initialize,
27
+ reset,
28
+ resultsSlice,
29
+ selectConfig,
30
+ selectIsHydrated,
31
+ selectLocale,
32
+ useTypedSelector,
33
+ } from '@/state';
24
34
 
25
35
  import styles from './index.module.scss';
26
36
 
@@ -64,7 +74,7 @@ const WEB_APPLICATION_JSON_LD = JSON.stringify({
64
74
  inLanguage: ['en-US', 'en-GB', 'de-DE', 'es-ES', 'fa-IR', 'fr-FR', 'pl-PL', 'ro-RO', 'tr-TR'],
65
75
  featureList: [
66
76
  'Finds the highest-scoring words for a given board and rack',
67
- 'Supports Scrabble, Super Scrabble, Scrabble Duel, Letter League, Crossplay, Literaki, and Kelimelik',
77
+ 'Supports Scrabble, Super Scrabble, Scrabble Duel, Letter League, Crossplay, Literaki, Kelimelik, and Wyrazy',
68
78
  'Dictionaries and word definitions in 8 languages',
69
79
  'Works offline once a dictionary is cached',
70
80
  'Free, open source, no ads, no sign-up',
@@ -84,6 +94,8 @@ const Index: FunctionComponent<Props> = ({ version }) => {
84
94
  const config = useTypedSelector(selectConfig);
85
95
  const locale = useTypedSelector(selectLocale);
86
96
  const isHydrated = useTypedSelector(selectIsHydrated);
97
+ const isCompactLayout = useIsCompactLayout();
98
+ const previousIsCompactLayout = useRef(isCompactLayout);
87
99
  const [modals, setModals] = useState<Record<Modal, boolean>>({
88
100
  dictionary: false,
89
101
  keyMap: false,
@@ -131,6 +143,7 @@ const Index: FunctionComponent<Props> = ({ version }) => {
131
143
  const handleCloseResults = useCallback(() => patchModals({ results: false }), [patchModals]);
132
144
  const handleCloseSettings = useCallback(() => patchModals({ settings: false }), [patchModals]);
133
145
  const handleCloseWords = useCallback(() => patchModals({ words: false }), [patchModals]);
146
+ const handlePreviewWord = useCallback(() => patchModals({ menu: false, words: false }), [patchModals]);
134
147
 
135
148
  useDirection(LOCALE_FEATURES[locale].direction);
136
149
  useLanguage(locale);
@@ -159,6 +172,16 @@ const Index: FunctionComponent<Props> = ({ version }) => {
159
172
  }
160
173
  }, [isHydrated]);
161
174
 
175
+ useEffect(() => {
176
+ if (previousIsCompactLayout.current === isCompactLayout) {
177
+ return;
178
+ }
179
+
180
+ previousIsCompactLayout.current = isCompactLayout;
181
+ dispatch(resultsSlice.actions.changeResultCandidate(null));
182
+ dispatch(hoveredWordSlice.actions.clear());
183
+ }, [dispatch, isCompactLayout]);
184
+
162
185
  useEffectOnce(() => {
163
186
  if (process.env.NODE_ENV === 'production') {
164
187
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -212,7 +235,9 @@ const Index: FunctionComponent<Props> = ({ version }) => {
212
235
 
213
236
  {mountedModals.keyMap && <KeyMapModal isOpen={modals.keyMap} onClose={handleCloseKeyMap} />}
214
237
 
215
- {mountedModals.words && <WordsModal isOpen={modals.words} onClose={handleCloseWords} />}
238
+ {mountedModals.words && (
239
+ <WordsModal isOpen={modals.words} onClose={handleCloseWords} onPreview={handlePreviewWord} />
240
+ )}
216
241
 
217
242
  {config.supportsRemainingTiles && mountedModals.remainingTiles && (
218
243
  <RemainingTilesModal isOpen={modals.remainingTiles} onClose={handleCloseRemainingTiles} />
@@ -8,6 +8,8 @@ export const BREAKPOINTS = {
8
8
  xl: Number(variables.breakpointXl),
9
9
  };
10
10
 
11
+ export const BREAKPOINT_HEIGHT_L = Number(variables.breakpointHeightL);
12
+
11
13
  export const GITHUB_PROJECT_URL = 'https://github.com/kamilmielnik/scrabble-solver';
12
14
 
13
15
  export const NPM_PACKAGE_URL = 'https://www.npmjs.com/package/scrabble-solver';
package/src/sdk/verify.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type BoardJson, type Game, type Locale } from '@scrabble-solver/types';
2
2
 
3
3
  import { verifyLocally } from '@/solver-worker';
4
+ import { type VerifiedWord } from '@/types';
4
5
 
5
6
  import { fetchJson } from './fetchJson';
6
7
 
@@ -11,8 +12,8 @@ interface Payload {
11
12
  }
12
13
 
13
14
  interface Response {
14
- invalidWords: string[];
15
- validWords: string[];
15
+ invalidWords: VerifiedWord[];
16
+ validWords: VerifiedWord[];
16
17
  }
17
18
 
18
19
  export const verify = async ({ board, game, locale }: Payload): Promise<Response> => {
@@ -2,7 +2,7 @@ import { type ResultJson } from '@scrabble-solver/types';
2
2
  import { afterAll, beforeEach, describe, expect, it } from 'bun:test';
3
3
 
4
4
  import type * as clientModule from './client';
5
- import { type SolverWorkerRequest, type SolverWorkerResponse } from './messages';
5
+ import { type SolverWorkerRequest, type SolverWorkerResponse, type VerifyResult } from './messages';
6
6
 
7
7
  type Listener = (event: { data: SolverWorkerResponse }) => void;
8
8
 
@@ -102,9 +102,9 @@ describe('solver worker client', () => {
102
102
 
103
103
  it('resolves a verify with the worker words', async () => {
104
104
  const verify = verifyLocally(verifyPayload);
105
- respond({ data: { invalidWords: ['zz'], validWords: ['ab'] }, id: lastRequestId(), outcome: 'answered' });
105
+ respond({ data: createVerifyResult(), id: lastRequestId(), outcome: 'answered' });
106
106
 
107
- expect(await verify).toEqual({ invalidWords: ['zz'], validWords: ['ab'] });
107
+ expect(await verify).toEqual(createVerifyResult());
108
108
  });
109
109
 
110
110
  it('does not wait for a prefetch to answer', () => {
@@ -149,3 +149,10 @@ function lastRequestId(): number {
149
149
  function createResults(word: string): ResultJson[] {
150
150
  return [{ blankIndices: [], id: 0, isHorizontal: true, points: 1, tiles: word.split(''), x: 0, y: 0 }];
151
151
  }
152
+
153
+ function createVerifyResult(): VerifyResult {
154
+ return {
155
+ invalidWords: [{ direction: 'horizontal', isValid: false, word: 'zz', x: 0, y: 0 }],
156
+ validWords: [{ direction: 'vertical', isValid: true, word: 'ab', x: 3, y: 5 }],
157
+ };
158
+ }
@@ -5,7 +5,7 @@ import { BLANK } from '@scrabble-solver/constants';
5
5
  import { solve } from '@scrabble-solver/solver';
6
6
  import { Board, type Locale, Tile } from '@scrabble-solver/types';
7
7
 
8
- import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
8
+ import { type SolveRequestPayload, type VerifiedWord, type VerifyRequestPayload } from '@/types';
9
9
 
10
10
  import { revalidateDictionary } from './dictionaries';
11
11
  import { getGaddag } from './getGaddag';
@@ -64,15 +64,15 @@ async function handleVerify(
64
64
  }
65
65
 
66
66
  const board = Board.fromJson(boardJson);
67
- const words = board.getWords().sort((a, b) => a.localeCompare(b, locale));
68
- const invalidWords: string[] = [];
69
- const validWords: string[] = [];
67
+ const words = board.getWords().sort((a, b) => a.word.localeCompare(b.word, locale));
68
+ const invalidWords: VerifiedWord[] = [];
69
+ const validWords: VerifiedWord[] = [];
70
70
 
71
71
  for (const word of words) {
72
- if (gaddag.has(word)) {
73
- validWords.push(word);
72
+ if (gaddag.has(word.word)) {
73
+ validWords.push({ ...word, isValid: true });
74
74
  } else {
75
- invalidWords.push(word);
75
+ invalidWords.push({ ...word, isValid: false });
76
76
  }
77
77
  }
78
78
 
@@ -1,10 +1,10 @@
1
1
  import { type Locale, type ResultJson } from '@scrabble-solver/types';
2
2
 
3
- import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
3
+ import { type SolveRequestPayload, type VerifiedWord, type VerifyRequestPayload } from '@/types';
4
4
 
5
5
  export interface VerifyResult {
6
- invalidWords: string[];
7
- validWords: string[];
6
+ invalidWords: VerifiedWord[];
7
+ validWords: VerifiedWord[];
8
8
  }
9
9
 
10
10
  export type SolverWorkerRequest =
@@ -0,0 +1,4 @@
1
+ export * from './initialState';
2
+ export * from './selectors';
3
+ export * from './slice';
4
+ export * from './types';
@@ -0,0 +1,5 @@
1
+ import type { HoveredWordState } from './types';
2
+
3
+ export const hoveredWordInitialState: HoveredWordState = {
4
+ word: null,
5
+ };
@@ -0,0 +1,5 @@
1
+ import type { VerifiedWord } from '@/types';
2
+
3
+ import type { RootState } from '../types';
4
+
5
+ export const selectHoveredWord = (state: RootState): VerifiedWord | null => state.hoveredWord.word;
@@ -0,0 +1,18 @@
1
+ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2
+
3
+ import { type VerifiedWord } from '@/types';
4
+
5
+ import { hoveredWordInitialState } from './initialState';
6
+
7
+ export const hoveredWordSlice = createSlice({
8
+ initialState: hoveredWordInitialState,
9
+ name: 'hoveredWord',
10
+ reducers: {
11
+ set: (state, action: PayloadAction<VerifiedWord>) => {
12
+ state.word = action.payload;
13
+ },
14
+ clear: (state) => {
15
+ state.word = null;
16
+ },
17
+ },
18
+ });
@@ -0,0 +1,5 @@
1
+ import type { VerifiedWord } from '@/types';
2
+
3
+ export interface HoveredWordState {
4
+ word: VerifiedWord | null;
5
+ }
@@ -4,6 +4,7 @@ export * from './board';
4
4
  export * from './cellFilters';
5
5
  export * from './dictionary';
6
6
  export * from './hoveredTile';
7
+ export * from './hoveredWord';
7
8
  export * from './i18n';
8
9
  export * from './localStorage';
9
10
  export * from './rack';
@@ -1,7 +1,8 @@
1
1
  import { Board, type BoardJson, isObject, type Locale } from '@scrabble-solver/types';
2
2
  import store2 from 'store2';
3
3
 
4
- import type { Rack, Translations } from '@/types';
4
+ import { englishTranslations } from '@/i18n/i18n';
5
+ import type { Rack, TranslationKey, Translations } from '@/types';
5
6
 
6
7
  import type { SettingsState } from './settings/types';
7
8
 
@@ -80,7 +81,13 @@ export const localStorage = {
80
81
  return undefined;
81
82
  }
82
83
 
83
- if (!isObject(stored) || stored.locale !== locale || stored.version !== version || !isObject(stored.translations)) {
84
+ if (
85
+ !isObject(stored) ||
86
+ stored.locale !== locale ||
87
+ stored.version !== version ||
88
+ !isObject(stored.translations) ||
89
+ !hasEveryTranslation(stored.translations)
90
+ ) {
84
91
  store.remove(TRANSLATIONS);
85
92
  return undefined;
86
93
  }
@@ -93,6 +100,10 @@ export const localStorage = {
93
100
  },
94
101
  };
95
102
 
103
+ function hasEveryTranslation(translations: Translations): boolean {
104
+ return (Object.keys(englishTranslations) as TranslationKey[]).every((key) => typeof translations[key] === 'string');
105
+ }
106
+
96
107
  /**
97
108
  * Introduced in 2.15.26 on 2026/04/27.
98
109
  * Life expectancy: 1y.
@@ -1,25 +1,15 @@
1
1
  import { type Result, type ShowCoordinates } from '@scrabble-solver/types';
2
2
 
3
+ import { createCoordinatesComparator } from '@/lib/createCoordinatesComparator';
3
4
  import { createKeyComparator } from '@/lib/createKeyComparator';
4
5
  import { createRegExp } from '@/lib/createRegExp';
5
- import { createStringComparator } from '@/lib/createStringComparator';
6
- import { getCoordinate } from '@/lib/getCoordinate';
7
- import { reverseComparator } from '@/lib/reverseComparator';
8
- import {
9
- type CellFilter,
10
- type Comparator,
11
- type GroupedResults,
12
- ResultColumnId,
13
- type Sort,
14
- SortDirection,
15
- } from '@/types';
6
+ import { createSortComparator } from '@/lib/createSortComparator';
7
+ import { getCoordinates as getPointCoordinates } from '@/lib/getCoordinates';
8
+ import { type CellFilter, type ComparatorFactory, type GroupedResults, ResultColumnId, type Sort } from '@/types';
16
9
 
17
10
  export const getCoordinates = (result: Result, showCoordinates: ShowCoordinates): string => {
18
11
  const firstCell = result.cells[0];
19
- const x = getCoordinate(firstCell.x, showCoordinates === 'original' ? 'letter' : 'number');
20
- const y = getCoordinate(firstCell.y, showCoordinates === 'original' ? 'number' : 'letter');
21
-
22
- return result.isHorizontal() ? `${y}${x}` : `${x}${y}`;
12
+ return getPointCoordinates({ isHorizontal: result.isHorizontal(), x: firstCell.x, y: firstCell.y }, showCoordinates);
23
13
  };
24
14
 
25
15
  export const groupResults = (
@@ -69,14 +59,11 @@ export const resultMatchesCellFilter = (result: Result, cellFilters: CellFilter[
69
59
  return matchesExcludeFilters && matchesIncludeFilters;
70
60
  };
71
61
 
72
- const comparators: Record<ResultColumnId, (locale: string, showCoordinates: ShowCoordinates) => Comparator<Result>> = {
62
+ const comparators: Record<ResultColumnId, ComparatorFactory<Result>> = {
73
63
  [ResultColumnId.BlanksCount]: (locale: string) => createKeyComparator('blanksCount', locale),
74
64
  [ResultColumnId.ConsonantsCount]: (locale: string) => createKeyComparator('consonantsCount', locale),
75
- [ResultColumnId.Coordinates]: (locale: string, showCoordinates: ShowCoordinates) => (a, b) => {
76
- const stringComparator = createStringComparator(locale);
77
- const aValue = getCoordinates(a, showCoordinates);
78
- const bValue = getCoordinates(b, showCoordinates);
79
- return stringComparator(aValue, bValue);
65
+ [ResultColumnId.Coordinates]: (locale: string, showCoordinates: ShowCoordinates) => {
66
+ return createCoordinatesComparator({ getItemCoordinates: getCoordinates, locale, showCoordinates });
80
67
  },
81
68
  [ResultColumnId.Points]: (locale: string) => createKeyComparator('points', locale),
82
69
  [ResultColumnId.TilesCount]: (locale: string) => createKeyComparator('tilesCount', locale),
@@ -95,9 +82,5 @@ export const sortResults = (
95
82
  return undefined;
96
83
  }
97
84
 
98
- const createComparator = comparators[sort.column];
99
- const comparator = createComparator(locale, showCoordinates);
100
- const finalComparator = sort.direction === SortDirection.Descending ? reverseComparator(comparator) : comparator;
101
- const sortedResults = [...results].sort(finalComparator);
102
- return sortedResults;
85
+ return [...results].sort(createSortComparator({ comparators, locale, showCoordinates, sort }));
103
86
  };
@@ -16,7 +16,7 @@ export const selectResultCandidate = createSelector([selectResults], (results) =
16
16
 
17
17
  export const selectResultsQuery = createSelector([selectResults], (results) => results.query);
18
18
 
19
- const selectResultsResults = createSelector([selectResults], (results) => results.results);
19
+ export const selectResultsResults = createSelector([selectResults], (results) => results.results);
20
20
 
21
21
  export const selectResultsSort = createSelector([selectResults], (results) => results.sort);
22
22
 
@@ -44,6 +44,14 @@ export const selectProcessedResults = createSelector([selectGroupedSortedResults
44
44
  return results ? [...results.matching, ...results.other] : undefined;
45
45
  });
46
46
 
47
+ export const selectResultCandidateIndex = createSelector(
48
+ [selectProcessedResults, selectResultCandidate],
49
+ (results, candidate) => {
50
+ const index = results && candidate ? results.findIndex((result) => result.id === candidate.id) : -1;
51
+ return index === -1 ? undefined : index;
52
+ },
53
+ );
54
+
47
55
  export const selectIsResultMatching = createSelector(
48
56
  [selectProcessedResults, selectResultsQuery, selectCellFilters, selectResultIndex],
49
57
  (results, query, cellFilters, index) => {
@@ -1,14 +1,11 @@
1
1
  import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2
2
  import { type Result } from '@scrabble-solver/types';
3
3
 
4
- import { type ResultColumnId, SortDirection } from '@/types';
4
+ import { getNextSort } from '@/lib/getNextSort';
5
+ import { type ResultColumnId } from '@/types';
5
6
 
6
7
  import { resultsInitialState } from './initialState';
7
8
 
8
- const toggleDirection = (direction: SortDirection): SortDirection => {
9
- return direction === SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending;
10
- };
11
-
12
9
  export const resultsSlice = createSlice({
13
10
  initialState: resultsInitialState,
14
11
  name: 'results',
@@ -24,7 +21,7 @@ export const resultsSlice = createSlice({
24
21
 
25
22
  changeResultCandidate: (state, action: PayloadAction<Result | null>) => {
26
23
  const candidate = action.payload;
27
- return { ...state, candidate };
24
+ return candidate === state.candidate ? state : { ...state, candidate };
28
25
  },
29
26
 
30
27
  changeResults: (state, action: PayloadAction<Result[]>) => {
@@ -38,16 +35,7 @@ export const resultsSlice = createSlice({
38
35
  },
39
36
 
40
37
  sort: (state, action: PayloadAction<ResultColumnId>) => {
41
- const columndId = action.payload;
42
- const { column, direction } = state.sort;
43
-
44
- return {
45
- ...state,
46
- sort: {
47
- column: columndId,
48
- direction: column === columndId ? toggleDirection(direction) : direction,
49
- },
50
- };
38
+ return { ...state, sort: getNextSort(state.sort, action.payload) };
51
39
  },
52
40
 
53
41
  reset: () => resultsInitialState,
@@ -14,12 +14,14 @@ import { memoize } from '@/lib/memoize';
14
14
  import { waitForFirstIntent, waitForIdleOrFirstIntent } from '@/lib/waitForIdleOrFirstIntent';
15
15
  import { findWordDefinitions, solve, verify, visit } from '@/sdk';
16
16
  import { prefetchDictionary } from '@/solver-worker';
17
+ import { type VerifiedWord } from '@/types';
17
18
 
18
19
  import { initialize, reset } from './actions';
19
20
  import { appSlice, selectVersion } from './app';
20
21
  import { boardSlice, selectBoard } from './board';
21
22
  import { cellFiltersSlice, selectCellFilter } from './cellFilters';
22
23
  import { dictionarySlice, selectDictionary } from './dictionary';
24
+ import { hoveredWordSlice } from './hoveredWord';
23
25
  import { i18nSlice, selectLoadedTranslations } from './i18n';
24
26
  import { localStorage } from './localStorage';
25
27
  import { rackSlice, selectCharacters, selectRack } from './rack';
@@ -51,6 +53,7 @@ export function* rootSaga(): AnyGenerator {
51
53
  yield takeEvery([rackSlice.actions.changeCharacter.type, rackSlice.actions.changeCharacters.type], onRackValueChange);
52
54
  yield takeEvery(resultsSlice.actions.applyResult.type, onApplyResult);
53
55
  yield takeLatest(resultsSlice.actions.changeResultCandidate.type, onResultCandidateChange);
56
+ yield takeLatest([hoveredWordSlice.actions.set.type, hoveredWordSlice.actions.clear.type], onHoveredWordChange);
54
57
  yield takeEvery(settingsSlice.actions.changeGame.type, onGameChange);
55
58
  yield takeEvery(settingsSlice.actions.changeLocale.type, onLocaleChange);
56
59
  yield takeLatest(dictionarySlice.actions.submit.type, onDictionarySubmit);
@@ -68,6 +71,7 @@ function* onCellValueChange({ payload }: PayloadAction<{ value: string; x: numbe
68
71
  }
69
72
 
70
73
  yield put(resultsSlice.actions.changeResultCandidate(null));
74
+ yield put(hoveredWordSlice.actions.clear());
71
75
  yield put(verifySlice.actions.submit());
72
76
  }
73
77
 
@@ -99,6 +103,7 @@ function* onGameChange(): AnyGenerator {
99
103
  }
100
104
 
101
105
  yield put(resultsSlice.actions.reset());
106
+ yield put(hoveredWordSlice.actions.clear());
102
107
  yield* resetRack();
103
108
  yield put(verifySlice.actions.submit());
104
109
  }
@@ -250,6 +255,7 @@ function* onReset(): AnyGenerator {
250
255
  yield put(boardSlice.actions.init(Board.create(config.boardWidth, config.boardHeight)));
251
256
  yield put(cellFiltersSlice.actions.reset());
252
257
  yield put(dictionarySlice.actions.reset());
258
+ yield put(hoveredWordSlice.actions.clear());
253
259
  yield put(rackSlice.actions.reset());
254
260
  yield put(resultsSlice.actions.reset());
255
261
  yield put(solveSlice.actions.reset());
@@ -280,6 +286,7 @@ function* onLocaleChange({ payload: locale }: PayloadAction<Locale>): AnyGenerat
280
286
  }
281
287
 
282
288
  yield put(dictionarySlice.actions.reset());
289
+ yield put(hoveredWordSlice.actions.clear());
283
290
  yield put(resultsSlice.actions.changeResultCandidate(null));
284
291
  yield put(verifySlice.actions.submit());
285
292
  }
@@ -289,8 +296,26 @@ function* onResultCandidateChange({ payload: result }: PayloadAction<Result | nu
289
296
  return;
290
297
  }
291
298
 
299
+ yield put(hoveredWordSlice.actions.clear());
300
+ yield* searchDictionary(result.words);
301
+ }
302
+
303
+ function* onHoveredWordChange({ payload: word }: PayloadAction<VerifiedWord | undefined>): AnyGenerator {
304
+ if (!word) {
305
+ return;
306
+ }
307
+
308
+ yield put(resultsSlice.actions.changeResultCandidate(null));
309
+
310
+ const board: Board = yield select(selectBoard);
311
+ const collidingWords = board.getCollidingWords(word);
312
+
313
+ yield* searchDictionary([word.word, ...collidingWords.map((collidingWord) => collidingWord.word)]);
314
+ }
315
+
316
+ function* searchDictionary(words: string[]): AnyGenerator {
292
317
  const locale: Locale = yield select(selectLocale);
293
- const uniqueWords = Array.from(new Set(result.words));
318
+ const uniqueWords = Array.from(new Set(words));
294
319
  const input = uniqueWords.join(LOCALE_FEATURES[locale].separator);
295
320
 
296
321
  if (!memoizedFindWordDefinitions.hasCache(locale, input)) {
@@ -3,10 +3,13 @@ import { createSelector } from '@reduxjs/toolkit';
3
3
  import { unorderedArraysEqual } from '@/lib/unorderedArraysEqual';
4
4
 
5
5
  import { selectBoard } from './board';
6
+ import { selectHoveredWord } from './hoveredWord';
6
7
  import { getRemainingTiles } from './lib';
7
8
  import { selectCharacters } from './rack';
9
+ import { selectResultsResults } from './results';
8
10
  import { selectConfig, selectLocale } from './settings';
9
- import { selectLastSolvedParameters } from './solve';
11
+ import { selectLastSolvedParameters, selectSolveError } from './solve';
12
+ import { selectLastVerifiedBoard } from './verify';
10
13
 
11
14
  const selectHasBoardChanged = createSelector(
12
15
  [selectLastSolvedParameters, selectBoard],
@@ -29,6 +32,20 @@ export const selectAreResultsOutdated = createSelector(
29
32
  },
30
33
  );
31
34
 
35
+ export const selectUpToDateResults = createSelector(
36
+ [selectResultsResults, selectAreResultsOutdated, selectSolveError],
37
+ (results, areResultsOutdated, solveError) => {
38
+ return results && !areResultsOutdated && !solveError ? results : null;
39
+ },
40
+ );
41
+
42
+ export const selectUpToDateHoveredWord = createSelector(
43
+ [selectHoveredWord, selectLastVerifiedBoard, selectBoard],
44
+ (hoveredWord, lastVerifiedBoard, board) => {
45
+ return hoveredWord && lastVerifiedBoard.equals(board) ? hoveredWord : null;
46
+ },
47
+ );
48
+
32
49
  export const selectRemainingTiles = createSelector(
33
50
  [selectConfig, selectBoard, selectCharacters, selectLocale],
34
51
  getRemainingTiles,
@@ -6,6 +6,7 @@ import { boardSlice } from './board';
6
6
  import { cellFiltersSlice } from './cellFilters';
7
7
  import { dictionarySlice } from './dictionary';
8
8
  import { hoveredTileSlice } from './hoveredTile';
9
+ import { hoveredWordSlice } from './hoveredWord';
9
10
  import { i18nSlice } from './i18n';
10
11
  import { rackSlice } from './rack';
11
12
  import { resultsSlice } from './results';
@@ -23,6 +24,7 @@ export const store = configureStore({
23
24
  cellFilters: cellFiltersSlice.reducer,
24
25
  dictionary: dictionarySlice.reducer,
25
26
  hoveredTile: hoveredTileSlice.reducer,
27
+ hoveredWord: hoveredWordSlice.reducer,
26
28
  i18n: i18nSlice.reducer,
27
29
  rack: rackSlice.reducer,
28
30
  results: resultsSlice.reducer,
@@ -3,6 +3,7 @@ import type { BoardState } from './board';
3
3
  import type { CellFiltersState } from './cellFilters';
4
4
  import type { DictionaryState } from './dictionary';
5
5
  import type { HoveredTileState } from './hoveredTile';
6
+ import type { HoveredWordState } from './hoveredWord';
6
7
  import type { I18nState } from './i18n';
7
8
  import type { RackState } from './rack';
8
9
  import type { ResultsState } from './results';
@@ -16,6 +17,7 @@ export type RootState = {
16
17
  cellFilters: CellFiltersState;
17
18
  dictionary: DictionaryState;
18
19
  hoveredTile: HoveredTileState;
20
+ hoveredWord: HoveredWordState;
19
21
  i18n: I18nState;
20
22
  rack: RackState;
21
23
  results: ResultsState;
@@ -1,3 +1,5 @@
1
+ import { SortDirection, WordColumnId } from '@/types';
2
+
1
3
  import { boardInitialState } from '../board';
2
4
 
3
5
  import type { VerifyState } from './types';
@@ -8,5 +10,10 @@ export const verifyInitialState: VerifyState = {
8
10
  board: boardInitialState,
9
11
  },
10
12
  invalidWords: [],
13
+ query: '',
14
+ sort: {
15
+ column: WordColumnId.Word,
16
+ direction: SortDirection.Ascending,
17
+ },
11
18
  validWords: [],
12
19
  };
@@ -0,0 +1,55 @@
1
+ import { SortDirection, type VerifiedWord, WordColumnId } from '../../types';
2
+
3
+ import { getWordCoordinates, sortWords } from './lib';
4
+
5
+ describe('getWordCoordinates', () => {
6
+ it('locates a horizontal word by its start cell', () => {
7
+ expect(getWordCoordinates(createWord({ x: 3, y: 3 }), 'original')).toBe('4D');
8
+ });
9
+
10
+ it('locates a vertical word by its start cell', () => {
11
+ expect(getWordCoordinates(createWord({ direction: 'vertical', x: 3, y: 3 }), 'original')).toBe('D4');
12
+ });
13
+ });
14
+
15
+ describe('sortWords', () => {
16
+ const cat = createWord({ word: 'cat', x: 3, y: 3 });
17
+ const zvq = createWord({ isValid: false, word: 'zvq', x: 3, y: 5 });
18
+
19
+ it('sorts by word', () => {
20
+ const sort = { column: WordColumnId.Word, direction: SortDirection.Ascending };
21
+
22
+ expect(sortWords([zvq, cat], sort, 'en-US', 'original')).toEqual([cat, zvq]);
23
+ });
24
+
25
+ it('sorts by coordinates', () => {
26
+ const sort = { column: WordColumnId.Coordinates, direction: SortDirection.Ascending };
27
+
28
+ expect(sortWords([zvq, cat], sort, 'en-US', 'original')).toEqual([cat, zvq]);
29
+ });
30
+
31
+ it('sorts invalid words first by validity', () => {
32
+ const sort = { column: WordColumnId.Validity, direction: SortDirection.Ascending };
33
+
34
+ expect(sortWords([cat, zvq], sort, 'en-US', 'original')).toEqual([zvq, cat]);
35
+ });
36
+
37
+ it('reverses the order when sorting descending', () => {
38
+ const sort = { column: WordColumnId.Word, direction: SortDirection.Descending };
39
+
40
+ expect(sortWords([cat, zvq], sort, 'en-US', 'original')).toEqual([zvq, cat]);
41
+ });
42
+
43
+ it('does not modify the input', () => {
44
+ const sort = { column: WordColumnId.Word, direction: SortDirection.Ascending };
45
+ const words = [zvq, cat];
46
+
47
+ sortWords(words, sort, 'en-US', 'original');
48
+
49
+ expect(words).toEqual([zvq, cat]);
50
+ });
51
+ });
52
+
53
+ function createWord(overrides: Partial<VerifiedWord>): VerifiedWord {
54
+ return { direction: 'horizontal', isValid: true, word: 'cat', x: 0, y: 0, ...overrides };
55
+ }