Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be2c3f63b0 | ||
|
|
aa0bdcf686 | ||
|
|
baea602000 | ||
|
|
05dd0b30d3 | ||
|
|
4febf5905b | ||
|
|
6739d59f2b | ||
|
|
cb20019c16 | ||
|
|
d90b5d5532 | ||
|
|
7028db0263 | ||
|
|
fa3b73c691 | ||
|
|
fd4d25c2d1 | ||
|
|
6501b073b1 | ||
|
|
9b4d9c21df | ||
|
|
53d23f19bc | ||
|
|
6c14012b97 | ||
|
|
c650ffa193 | ||
|
|
28834e8a3e | ||
|
|
8681b17340 | ||
|
|
d52cc608eb | ||
|
|
6f89d9e274 |
70
CHANGELOG.md
70
CHANGELOG.md
@@ -1,3 +1,73 @@
|
||||
## [4.44.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.1...v4.44.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** match top/bottom margins to left padding on kyu detail boxes ([aa0bdcf](https://github.com/antialias/soroban-abacus-flashcards/commit/aa0bdcf686adcbfd1a145cf67121181d1f1194d9))
|
||||
|
||||
## [4.44.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.0...v4.44.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed dimensions and margins to kyu detail boxes ([05dd0b3](https://github.com/antialias/soroban-abacus-flashcards/commit/05dd0b30d3c397b82b7b7cc93a5ea575f3aada6d))
|
||||
|
||||
## [4.44.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.2...v4.44.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** redesign kyu details with larger operators and prominent digits ([6739d59](https://github.com/antialias/soroban-abacus-flashcards/commit/6739d59f2b6189a98570e23e04c20d86d774ccce))
|
||||
|
||||
## [4.43.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.1...v4.43.2) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** remove Time and Pass sections from kyu details ([d90b5d5](https://github.com/antialias/soroban-abacus-flashcards/commit/d90b5d55322e75dd28b95376614663a506c829d4))
|
||||
|
||||
## [4.43.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.0...v4.43.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** use two-column grid for kyu details to prevent clipping ([fa3b73c](https://github.com/antialias/soroban-abacus-flashcards/commit/fa3b73c69169b4694201ffa19ae3f8b5a68dfe32))
|
||||
|
||||
## [4.43.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.42.1...v4.43.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add structured kyu exam details with card UI ([6501b07](https://github.com/antialias/soroban-abacus-flashcards/commit/6501b073b100a00982cff1ca3140921e74f31a9c))
|
||||
|
||||
## [4.42.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.42.0...v4.42.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** store kyu data verbatim, add formatting layer ([53d23f1](https://github.com/antialias/soroban-abacus-flashcards/commit/53d23f19bc06459462afb76ed94d9b99d583a32d))
|
||||
|
||||
## [4.42.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.41.0...v4.42.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add kyu level details display with English translations ([c650ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/c650ffa1935fe370d37190b2843c0deecdcce8e7))
|
||||
|
||||
## [4.41.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.40.1...v4.41.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** right-align abacus display ([8681b17](https://github.com/antialias/soroban-abacus-flashcards/commit/8681b17340e757cf04d17f884a780a251645bb33))
|
||||
|
||||
## [4.40.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.40.0...v4.40.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** increase animation speed to 10ms for 10th Dan ([6f89d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/6f89d9e274082908fc090a9c0ba310f2cb06f014))
|
||||
|
||||
## [4.40.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.39.1...v4.40.0) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AbacusReact, StandaloneBead } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack } from '../../../styled-system/patterns'
|
||||
import { kyuLevelDetails } from '@/data/kyuLevelDetails'
|
||||
|
||||
// Combine all levels into one array for the slider
|
||||
const allLevels = [
|
||||
@@ -181,6 +182,85 @@ const allLevels = [
|
||||
},
|
||||
] as const
|
||||
|
||||
// Helper function to map level names to kyuLevelDetails keys
|
||||
function getLevelDetailsKey(levelName: string): string | null {
|
||||
// Convert "10th Kyu" → "10-kyu", "3rd Kyu" → "3-kyu", etc.
|
||||
const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/)
|
||||
if (match) {
|
||||
return `${match[1]}-kyu`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse and format kyu level details into structured sections with icons
|
||||
function parseKyuDetails(rawText: string) {
|
||||
const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp'))
|
||||
|
||||
// Always return sections in consistent order: Add/Sub, Multiply, Divide
|
||||
const sections: Array<{
|
||||
type: 'addSub' | 'multiply' | 'divide'
|
||||
icon: string
|
||||
label: string
|
||||
digits: string | null
|
||||
rows: string | null
|
||||
chars: string | null
|
||||
problems: string | null
|
||||
}> = [
|
||||
{
|
||||
type: 'addSub',
|
||||
icon: '➕➖',
|
||||
label: 'Add/Sub',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
{
|
||||
type: 'multiply',
|
||||
icon: '✖️',
|
||||
label: 'Multiply',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
{
|
||||
type: 'divide',
|
||||
icon: '➗',
|
||||
label: 'Divide',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
]
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Add/Sub:')) {
|
||||
const match = line.match(/(\d+)-digit.*?(\d+)口.*?(\d+)字/)
|
||||
if (match) {
|
||||
sections[0].digits = match[1]
|
||||
sections[0].rows = match[2]
|
||||
sections[0].chars = match[3]
|
||||
}
|
||||
} else if (line.includes('×:')) {
|
||||
const match = line.match(/(\d+) digits.*?\((\d+)/)
|
||||
if (match) {
|
||||
sections[1].digits = match[1]
|
||||
sections[1].problems = match[2]
|
||||
}
|
||||
} else if (line.includes('÷:')) {
|
||||
const match = line.match(/(\d+) digits.*?\((\d+)/)
|
||||
if (match) {
|
||||
sections[2].digits = match[1]
|
||||
sections[2].problems = match[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export default function LevelsPage() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
@@ -202,17 +282,18 @@ export default function LevelsPage() {
|
||||
useEffect(() => {
|
||||
// Calculate animation speed based on level
|
||||
// Kyu levels: 500ms
|
||||
// Dan levels: interpolate from 500ms (Pre-1st Dan) to 50ms (10th Dan)
|
||||
// Pre-1st Dan: 500ms
|
||||
// 1st-10th Dan: interpolate from 500ms to 10ms
|
||||
const getAnimationInterval = () => {
|
||||
if (currentIndex < 10) {
|
||||
// Kyu levels: constant 500ms
|
||||
if (currentIndex < 11) {
|
||||
// Kyu levels and Pre-1st Dan: constant 500ms
|
||||
return 500
|
||||
}
|
||||
// Dan levels: speed up from 500ms to 50ms
|
||||
// Index 10 (Pre-1st Dan) → 500ms
|
||||
// Index 20 (10th Dan) → 50ms
|
||||
const danProgress = (currentIndex - 10) / 10 // 0.0 to 1.0
|
||||
return 500 - danProgress * 450 // 500ms down to 50ms
|
||||
// 1st Dan through 10th Dan: speed up from 500ms to 10ms
|
||||
// Index 11 (1st Dan) → 500ms
|
||||
// Index 20 (10th Dan) → 10ms
|
||||
const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0
|
||||
return 500 - danProgress * 490 // 500ms down to 10ms
|
||||
}
|
||||
|
||||
const intervalMs = getAnimationInterval()
|
||||
@@ -575,12 +656,11 @@ export default function LevelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display */}
|
||||
{/* Abacus Display with Level Details */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '4',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
@@ -590,19 +670,149 @@ export default function LevelsPage() {
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
{/* Level Details (only for Kyu levels) */}
|
||||
{currentLevel.type === 'kyu' &&
|
||||
(() => {
|
||||
const detailsKey = getLevelDetailsKey(currentLevel.level)
|
||||
const rawText = detailsKey
|
||||
? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails]
|
||||
: null
|
||||
const sections = rawText ? parseKyuDetails(rawText) : []
|
||||
|
||||
// Use consistent sizing across all levels
|
||||
const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' }
|
||||
|
||||
return sections.length > 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: sizing.gap,
|
||||
pr: '4',
|
||||
pl: '2',
|
||||
borderRight: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
maxW: '480px',
|
||||
alignContent: 'center',
|
||||
my: '2',
|
||||
})}
|
||||
>
|
||||
{sections.map((section, idx) => {
|
||||
const hasData = section.digits !== null
|
||||
const levelColor =
|
||||
currentLevel.color === 'green'
|
||||
? 'green.300'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.300'
|
||||
: 'violet.300'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={css({
|
||||
bg: hasData ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.2)',
|
||||
border: '1px solid',
|
||||
borderColor: hasData ? 'gray.700' : 'gray.800',
|
||||
rounded: 'md',
|
||||
p: '4',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2',
|
||||
opacity: hasData ? 1 : 0.3,
|
||||
width: '200px',
|
||||
height: '180px',
|
||||
_hover: hasData
|
||||
? {
|
||||
borderColor: 'gray.500',
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
: {},
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: sizing.iconSize, lineHeight: '1' })}>
|
||||
{section.icon}
|
||||
</span>
|
||||
{hasData && section.digits && (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: levelColor,
|
||||
})}
|
||||
>
|
||||
{section.digits} digits
|
||||
</div>
|
||||
{(section.rows || section.chars) && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{section.rows && `${section.rows} rows`}
|
||||
{section.rows && section.chars && ' • '}
|
||||
{section.chars && `${section.chars} chars`}
|
||||
</div>
|
||||
)}
|
||||
{section.problems && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{section.problems} problems
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: hasData ? 'gray.500' : 'gray.700',
|
||||
textAlign: 'center',
|
||||
fontWeight: hasData ? 'normal' : 'bold',
|
||||
})}
|
||||
>
|
||||
{section.label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: currentLevel.type === 'kyu' ? 'flex-end' : 'center',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
|
||||
123
apps/web/src/data/kyuLevelDetails.ts
Normal file
123
apps/web/src/data/kyuLevelDetails.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Detailed requirements for each Kyu level in the Soroban certification system
|
||||
* Source: shuzan.jp
|
||||
*
|
||||
* Note: Stored verbatim from source. Display formatting/translation happens in the UI layer.
|
||||
*/
|
||||
|
||||
export const kyuLevelDetails = {
|
||||
'10-kyu': `Add/Sub: 2-digit, 5口, 10字
|
||||
|
||||
×: 実+法 = 3 digits (20 problems)
|
||||
|
||||
Time: 20 min; Pass ≥ 60/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'9-kyu': `Add/Sub: 2-digit, 5口, 10字
|
||||
|
||||
×: 実+法 = 3 digits (20)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200. (If only one part clears, it's treated as 10-kyu per federation notes.)
|
||||
shuzan.jp`,
|
||||
|
||||
'8-kyu': `Add/Sub: 2-digit, 8口, 16字
|
||||
|
||||
×: 実+法 = 4 digits (10)
|
||||
|
||||
÷: 法+商 = 3 digits (10)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'7-kyu': `Add/Sub: 2-digit, 10口, 20字
|
||||
|
||||
×: 実+法 = 4 digits (10)
|
||||
|
||||
÷: 法+商 = 4 digits (10)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'6-kyu': `Add/Sub: 10口, 30字
|
||||
|
||||
×: 実+法 = 5 digits (20)
|
||||
|
||||
÷: 法+商 = 4 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'5-kyu': `Add/Sub: 10口, 40字
|
||||
|
||||
×: 実+法 = 6 digits (20)
|
||||
|
||||
÷: 法+商 = 5 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'4-kyu': `Add/Sub: 10口, 50字
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-3-kyu': `Add/Sub: 10口, 50字 ×5題 and 10口, 60字 ×5題 (total 10)
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'3-kyu': `Add/Sub: 10口, 60字
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-2-kyu': `Add/Sub: 10口, 70字
|
||||
|
||||
×: 実+法 = 8 digits (20)
|
||||
|
||||
÷: 法+商 = 7 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'2-kyu': `Add/Sub: 10口, 80字
|
||||
|
||||
×: 実+法 = 9 digits (20)
|
||||
|
||||
÷: 法+商 = 8 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-1-kyu': `Add/Sub: 10口, 90字
|
||||
|
||||
×: 実+法 = 10 digits (20)
|
||||
|
||||
÷: 法+商 = 9 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'1-kyu': `Add/Sub: 10口, 100字
|
||||
|
||||
×: 実+法 = 11 digits (20)
|
||||
|
||||
÷: 法+商 = 10 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
} as const
|
||||
|
||||
export type KyuLevel = keyof typeof kyuLevelDetails
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.40.0",
|
||||
"version": "4.44.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user