improve jumbled exercise: rename, add feedback option, functionality

This commit is contained in:
Erza Bytyci
2026-05-25 07:59:20 +00:00
parent d6dab8e72e
commit 5cc4db08bb
15 changed files with 559 additions and 71 deletions
@@ -0,0 +1,4 @@
ALTER TABLE public.jumbled_sentence_sentences
ADD COLUMN IF NOT EXISTS correct_feedback text,
ADD COLUMN IF NOT EXISTS alternate_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[],
ADD COLUMN IF NOT EXISTS incorrect_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[];
@@ -0,0 +1,2 @@
ALTER TABLE public.jumbled_sentence_sentences
ADD COLUMN IF NOT EXISTS incorrect_alternates text[] NOT NULL DEFAULT ARRAY[]::text[];
+5 -1
View File
@@ -423,7 +423,11 @@ CREATE TABLE public.jumbled_sentence_sentences (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
jumbled_exercise_id bigint NOT NULL,
sentence text NOT NULL,
alternates text[] DEFAULT ARRAY[]::text[]
alternates text[] DEFAULT ARRAY[]::text[],
correct_feedback text,
alternate_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[],
incorrect_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[],
incorrect_alternates text[] NOT NULL DEFAULT ARRAY[]::text[]
);
CREATE TABLE public.jumbled_sentence_exercises (
@@ -23,6 +23,18 @@
box-sizing: border-box;
}
.sentenceHint {
font-size: 0.9rem;
font-style: italic;
font-weight: 500;
color: var(--fg4);
}
.sentenceTextarea {
resize: vertical;
line-height: 1.4;
}
.sentenceCard {
border: 1px solid #ccc;
padding: 0.5rem;
@@ -30,12 +42,33 @@
width: 40rem;
}
.alternateGroup {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0;
}
.alternateGroup+.alternateGroup {
margin-top: 0.4rem;
}
.alternateRow {
position: relative;
width: min(100%, var(--jumbled-input-max-width));
margin-top: 0.5rem;
}
.alternateGroup>.alternateRow {
margin-top: 0;
}
.alternateRowNoGapTop {
position: relative;
width: min(100%, var(--jumbled-input-max-width));
margin-top: 0;
}
.alternateInput {
padding-right: 2rem;
}
@@ -56,15 +89,51 @@
}
.addAlternateButton {
min-width: 14rem;
width: fit-content;
white-space: nowrap;
}
.fitContentButton {
width: auto;
min-width: 18rem;
white-space: nowrap;
justify-content: center;
}
.inlineActionsRow {
width: min(100%, var(--jumbled-input-max-width));
margin-top: 0.5rem;
}
.feedbackSection {
width: min(100%, var(--jumbled-input-max-width));
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0;
}
.sectionTitle {
margin-bottom: 0;
font-weight: 600;
}
.sectionTitle+.alternateRowNoGapTop {
margin-top: 0;
}
.alternateRowNoGapTop+.alternateRowNoGapTop {
margin-top: 0.4rem;
}
.feedbackSection>.fitContentButton {
margin-top: 0.5rem;
}
.feedbackSection>.sectionTitle+.sectionTitle {
margin-top: 0.5rem;
}
.removeSentenceButton {
background-color: #f8d7da;
border-color: #f5c2c7;
@@ -9,13 +9,25 @@ import { withBasePath } from "@/shared/utils/basePath";
import useQuery from "@/shared/hooks/useQuery";
import styles from "./jumbled-sentence.module.css";
function toWordPerLine(value) {
if (typeof value !== "string") return "";
return value.trim().split(/\s+/).filter(Boolean).join("\n");
}
export default function CreateJumbledSentenceExercise() {
const router = useRouter();
const { exercise_id } = useParams();
const isEditMode = Boolean(exercise_id);
const [title, setTitle] = useState("");
const [sentences, setSentences] = useState([
{ sentence: "", alternates: [""] },
{
sentence: "",
alternates: [""],
alternateFeedbacks: [""],
correctSentenceFeedback: "",
incorrectAlternates: [""],
incorrectFeedbacks: [""],
},
]);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -38,13 +50,47 @@ export default function CreateJumbledSentenceExercise() {
setSentences(
(loadedExercise.sentences || []).length
? loadedExercise.sentences.map((item) => ({
sentence: item.sentence || "",
sentence: toWordPerLine(item.sentence || ""),
alternates:
item.alternates && item.alternates.length > 0
? item.alternates
: [""],
alternateFeedbacks:
item.alternateFeedbacks &&
item.alternateFeedbacks.length === item.alternates?.length
? item.alternateFeedbacks
: new Array(
item.alternates && item.alternates.length > 0
? item.alternates.length
: 1
).fill(""),
correctSentenceFeedback: item.correctSentenceFeedback || "",
incorrectAlternates:
item.incorrectAlternates && item.incorrectAlternates.length > 0
? item.incorrectAlternates
: [""],
incorrectFeedbacks:
item.incorrectFeedbacks &&
item.incorrectFeedbacks.length ===
(item.incorrectAlternates?.length || 0)
? item.incorrectFeedbacks
: new Array(
item.incorrectAlternates &&
item.incorrectAlternates.length > 0
? item.incorrectAlternates.length
: 1
).fill(""),
}))
: [{ sentence: "", alternates: [""] }]
: [
{
sentence: "",
alternates: [""],
alternateFeedbacks: [""],
correctSentenceFeedback: "",
incorrectAlternates: [""],
incorrectFeedbacks: [""],
},
]
);
}, [isEditMode, data]);
@@ -60,13 +106,62 @@ export default function CreateJumbledSentenceExercise() {
setSentences(updated);
};
const handleAlternateFeedbackChange = (idx, altIdx, value) => {
const updated = [...sentences];
updated[idx].alternateFeedbacks[altIdx] = value;
setSentences(updated);
};
const handleCorrectFeedbackChange = (idx, value) => {
const updated = [...sentences];
updated[idx].correctSentenceFeedback = value;
setSentences(updated);
};
const handleIncorrectFeedbackChange = (idx, fbIdx, value) => {
const updated = [...sentences];
updated[idx].incorrectFeedbacks[fbIdx] = value;
setSentences(updated);
};
const handleIncorrectAlternateChange = (idx, altIdx, value) => {
const updated = [...sentences];
updated[idx].incorrectAlternates[altIdx] = value;
setSentences(updated);
};
const addSentence = () => {
setSentences([...sentences, { sentence: "", alternates: [""] }]);
setSentences([
...sentences,
{
sentence: "",
alternates: [""],
alternateFeedbacks: [""],
correctSentenceFeedback: "",
incorrectAlternates: [""],
incorrectFeedbacks: [""],
},
]);
};
const addAlternate = (idx) => {
const updated = [...sentences];
updated[idx].alternates.push("");
updated[idx].alternateFeedbacks.push("");
setSentences(updated);
};
const addIncorrectFeedback = (idx) => {
const updated = [...sentences];
updated[idx].incorrectAlternates.push("");
updated[idx].incorrectFeedbacks.push("");
setSentences(updated);
};
const addIncorrectAlternate = (idx) => {
const updated = [...sentences];
updated[idx].incorrectAlternates.push("");
updated[idx].incorrectFeedbacks.push("");
setSentences(updated);
};
@@ -79,15 +174,87 @@ export default function CreateJumbledSentenceExercise() {
updated[idx].alternates = updated[idx].alternates.filter(
(_, i) => i !== altIdx
);
updated[idx].alternateFeedbacks = updated[idx].alternateFeedbacks.filter(
(_, i) => i !== altIdx
);
if (updated[idx].alternates.length === 0) {
updated[idx].alternates = [""];
updated[idx].alternateFeedbacks = [""];
}
setSentences(updated);
};
const removeIncorrectAlternate = (idx, altIdx) => {
const updated = [...sentences];
updated[idx].incorrectAlternates = updated[idx].incorrectAlternates.filter(
(_, i) => i !== altIdx
);
updated[idx].incorrectFeedbacks = updated[idx].incorrectFeedbacks.filter(
(_, i) => i !== altIdx
);
if (updated[idx].incorrectAlternates.length === 0) {
updated[idx].incorrectAlternates = [""];
updated[idx].incorrectFeedbacks = [""];
}
setSentences(updated);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
const normalizedSentences = sentences.map((item) => ({
...item,
sentence: toWordPerLine(item.sentence),
alternates: item.alternates
.map((alt, index) => ({
alt: typeof alt === "string" ? alt.trim() : "",
feedback:
typeof item.alternateFeedbacks?.[index] === "string"
? item.alternateFeedbacks[index].trim()
: "",
}))
.filter((entry) => entry.alt.length > 0)
.map((entry) => entry.alt),
alternateFeedbacks: item.alternates
.map((alt, index) => ({
alt: typeof alt === "string" ? alt.trim() : "",
feedback:
typeof item.alternateFeedbacks?.[index] === "string"
? item.alternateFeedbacks[index].trim()
: "",
}))
.filter((entry) => entry.alt.length > 0)
.map((entry) => entry.feedback),
correctSentenceFeedback:
typeof item.correctSentenceFeedback === "string"
? item.correctSentenceFeedback.trim()
: "",
incorrectAlternates: (item.incorrectAlternates || [])
.map((alt, index) => ({
alt: typeof alt === "string" ? alt.trim() : "",
feedback:
typeof item.incorrectFeedbacks?.[index] === "string"
? item.incorrectFeedbacks[index].trim()
: "",
}))
.filter((entry) => entry.alt.length > 0)
.map((entry) => entry.alt),
incorrectFeedbacks: (item.incorrectAlternates || [])
.map((alt, index) => ({
alt: typeof alt === "string" ? alt.trim() : "",
feedback:
typeof item.incorrectFeedbacks?.[index] === "string"
? item.incorrectFeedbacks[index].trim()
: "",
}))
.filter((entry) => entry.alt.length > 0)
.map((entry) => entry.feedback),
}));
const parsed = jumbledSentenceExerciseSchema.safeParse({
title,
sentences,
sentences: normalizedSentences,
});
if (!parsed.success) {
setError(
@@ -105,7 +272,7 @@ export default function CreateJumbledSentenceExercise() {
const res = await fetch(endpoint, {
method: isEditMode ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, sentences }),
body: JSON.stringify({ title, sentences: normalizedSentences }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Fehler beim Speichern.");
@@ -133,9 +300,7 @@ export default function CreateJumbledSentenceExercise() {
<form onSubmit={handleSubmit} className={styles.form}>
<Column gap="md">
<h2>
{isEditMode
? "Jumbled Sentence Übung bearbeiten"
: "Jumbled Sentence Übung erstellen"}
{isEditMode ? "Satzmix-Übung bearbeiten" : "Satzmix-Übung erstellen"}
</h2>
<label className={styles.fieldLabel}>
Titel:
@@ -147,44 +312,71 @@ export default function CreateJumbledSentenceExercise() {
/>
</label>
<p className={styles.exerciseDescription}>
Trage den Satz in der richtigen Reihenfolge ein und optional
alternative Reihenfolgen mit <br></br> denselben Wörtern. Für die
Lernenden werden die Wörter anschließend gemischt angezeigt.
Geben Sie den Satz in der richtigen Reihenfolge und optional in
alternativen Reihenfolgen <br></br> mit denselben Elementen ein. Die
Elemente werden den Lernenden anschließend in zufälliger <br></br>
Reihenfolge angezeigt.
</p>
{sentences.map((s, idx) => (
<div key={idx} className={styles.sentenceCard}>
<label className={styles.fieldLabel}>
Satz (korrekt):
<input
<span className={styles.sentenceHint}>
Schreiben Sie jedes Element in eine neue Zeile.
</span>
<textarea
value={s.sentence}
onChange={(e) => handleSentenceChange(idx, e.target.value)}
className={styles.fieldInput}
className={`${styles.fieldInput} ${styles.sentenceTextarea}`}
rows={6}
placeholder={"Ich\nlerne\njeden\nTag\nDeutsch"}
required
/>
</label>
<label className={styles.fieldLabel}>
Feedback für korrekten Satz (optional):
<input
value={s.correctSentenceFeedback}
onChange={(e) =>
handleCorrectFeedbackChange(idx, e.target.value)
}
className={styles.fieldInput}
placeholder="Sehr gut!"
/>
</label>
<div>
Alternativ-Sätze:
<div className={styles.sectionTitle}>Alternativ-Sätze:</div>
{s.alternates.map((alt, altIdx) => (
<div key={altIdx} className={styles.alternateRow}>
<div key={altIdx} className={styles.alternateGroup}>
<div className={styles.alternateRow}>
<input
value={alt}
onChange={(e) =>
handleAlternateChange(idx, altIdx, e.target.value)
}
className={`${styles.fieldInput} ${styles.alternateInput}`}
/>
<Button
type="button"
onClick={() => removeAlternate(idx, altIdx)}
disabled={s.alternates.length === 1}
variant="none"
width="fit"
className={styles.alternateDeleteButton}
style={{ color: "#c62828" }}
aria-label="Alternative entfernen"
>
X
</Button>
</div>
<input
value={alt}
value={s.alternateFeedbacks[altIdx] || ""}
onChange={(e) =>
handleAlternateChange(idx, altIdx, e.target.value)
handleAlternateFeedbackChange(idx, altIdx, e.target.value)
}
className={`${styles.fieldInput} ${styles.alternateInput}`}
className={styles.fieldInput}
placeholder="Feedback für diese Alternative (optional)"
/>
<Button
type="button"
onClick={() => removeAlternate(idx, altIdx)}
disabled={s.alternates.length === 1}
variant="none"
width="fit"
className={styles.alternateDeleteButton}
style={{ color: "#c62828" }}
aria-label="Alternative entfernen"
>
X
</Button>
</div>
))}
<Row
@@ -195,7 +387,7 @@ export default function CreateJumbledSentenceExercise() {
<Button
type="button"
onClick={() => addAlternate(idx)}
className={styles.addAlternateButton}
className={styles.fitContentButton}
>
Alternative hinzufügen
</Button>
@@ -214,6 +406,56 @@ export default function CreateJumbledSentenceExercise() {
</Button>
</Row>
</div>
<div className={styles.feedbackSection}>
<div className={styles.sectionTitle}>
Alternativ-Sätze (Falsche):
</div>
{s.incorrectAlternates.map((alt, altIdx) => (
<div key={altIdx} className={styles.alternateGroup}>
<div className={styles.alternateRowNoGapTop}>
<input
value={alt}
onChange={(e) =>
handleIncorrectAlternateChange(
idx,
altIdx,
e.target.value
)
}
className={`${styles.fieldInput} ${styles.alternateInput}`}
placeholder="Falsche Alternative (optional)"
/>
<Button
type="button"
onClick={() => removeIncorrectAlternate(idx, altIdx)}
disabled={s.incorrectAlternates.length === 1}
variant="none"
width="fit"
className={styles.alternateDeleteButton}
style={{ color: "#c62828" }}
aria-label="Falsche Alternative entfernen"
>
X
</Button>
</div>
<input
value={s.incorrectFeedbacks[altIdx] || ""}
onChange={(e) =>
handleIncorrectFeedbackChange(idx, altIdx, e.target.value)
}
className={styles.fieldInput}
placeholder="Feedback für diese falsche Alternative (optional)"
/>
</div>
))}
<Button
type="button"
onClick={() => addIncorrectAlternate(idx)}
className={styles.fitContentButton}
>
Alternative hinzufügen (Falsche)
</Button>
</div>
</div>
))}
{error && <div className={styles.errorText}>{error}</div>}
@@ -29,7 +29,7 @@ export default function CreateExercise() {
Memory Game
</LinkButton>
<LinkButton href="/admin/create-exercise/jumbled-sentence">
Jumbled Sentence
Satzmix
</LinkButton>
</Column>
);
@@ -15,12 +15,16 @@ export const GET = withAuth(async (request, { params }) => {
}
const exercise = exRes.rows[0];
const sRes = await DB.pool(
`SELECT sentence, alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
`SELECT sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
[id]
);
exercise.sentences = sRes.rows.map((row) => ({
sentence: row.sentence,
alternates: row.alternates || [],
correctSentenceFeedback: row.correct_feedback || "",
alternateFeedbacks: row.alternate_feedbacks || [],
incorrectFeedbacks: row.incorrect_feedbacks || [],
incorrectAlternates: row.incorrect_alternates || [],
}));
return NextResponse.json({ exercise });
} catch (err) {
@@ -38,7 +42,7 @@ export const PUT = withAuth(
{
error:
parseResult.error.issues[0]?.message ||
"Ungültige Eingabe für Jumbled Sentence.",
"Ungültige Eingabe für Satzmix-Übung.",
issues: parseResult.error.issues,
},
{ status: 400 }
@@ -57,8 +61,16 @@ export const PUT = withAuth(
);
for (const s of sentences) {
await client.query(
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates) VALUES ($1, $2, $3)`,
[id, s.sentence, s.alternates?.filter(Boolean) || []]
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
s.sentence,
s.alternates?.filter(Boolean) || [],
s.correctSentenceFeedback || null,
s.alternateFeedbacks || [],
s.incorrectFeedbacks || [],
s.incorrectAlternates || [],
]
);
}
});
@@ -12,7 +12,7 @@ export const POST = withAuth(
{
error:
parseResult.error.issues[0]?.message ||
"Ungültige Eingabe für Jumbled Sentence.",
"Ungültige Eingabe für Satzmix-Übung.",
issues: parseResult.error.issues,
},
{ status: 400 }
@@ -34,8 +34,16 @@ export const POST = withAuth(
const jumbled_id = jumbledRes.rows[0].id;
for (const s of sentences) {
await client.query(
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates) VALUES ($1, $2, $3)`,
[jumbled_id, s.sentence, s.alternates?.filter(Boolean) || []]
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
jumbled_id,
s.sentence,
s.alternates?.filter(Boolean) || [],
s.correctSentenceFeedback || null,
s.alternateFeedbacks || [],
s.incorrectFeedbacks || [],
s.incorrectAlternates || [],
]
);
}
return { id: jumbled_id };
@@ -18,6 +18,10 @@ describe("admin jumbled sentence exercises API", () => {
{
sentence: "Ich lerne jeden Tag Deutsch",
alternates: ["Jeden Tag lerne ich Deutsch"],
alternateFeedbacks: ["Auch richtig."],
correctSentenceFeedback: "Perfekt!",
incorrectAlternates: ["Deutsch ich lerne jeden Tag"],
incorrectFeedbacks: ["Subjekt und Verb bitte prüfen."],
},
],
};
@@ -48,7 +52,7 @@ describe("admin jumbled sentence exercises API", () => {
expect(linkedExercise.rows[0].created_by).toBe(admin.id);
const sentences = await DB.pool(
"SELECT sentence, alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1",
"SELECT sentence, alternates, alternate_feedbacks, correct_feedback, incorrect_alternates, incorrect_feedbacks FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1",
[json.id]
);
expect(sentences.rows.length).toBe(1);
@@ -56,6 +60,18 @@ describe("admin jumbled sentence exercises API", () => {
expect(sentences.rows[0].alternates).toEqual(
payload.sentences[0].alternates
);
expect(sentences.rows[0].alternate_feedbacks).toEqual(
payload.sentences[0].alternateFeedbacks
);
expect(sentences.rows[0].correct_feedback).toBe(
payload.sentences[0].correctSentenceFeedback
);
expect(sentences.rows[0].incorrect_alternates).toEqual(
payload.sentences[0].incorrectAlternates
);
expect(sentences.rows[0].incorrect_feedbacks).toEqual(
payload.sentences[0].incorrectFeedbacks
);
});
it("returns 400 for invalid payload", async () => {
@@ -14,12 +14,16 @@ export async function GET(request, { params }) {
}
const exercise = exRes.rows[0];
const sRes = await DB.pool(
`SELECT sentence, alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
`SELECT sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
[id]
);
exercise.sentences = sRes.rows.map((row) => ({
sentence: row.sentence,
alternates: row.alternates || [],
correctSentenceFeedback: row.correct_feedback || "",
alternateFeedbacks: row.alternate_feedbacks || [],
incorrectFeedbacks: row.incorrect_feedbacks || [],
incorrectAlternates: row.incorrect_alternates || [],
}));
return NextResponse.json({ exercise });
} catch (err) {
@@ -50,8 +54,16 @@ export async function PUT(request, { params }) {
);
for (const s of sentences) {
await client.query(
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates) VALUES ($1, $2, $3)`,
[id, s.sentence, s.alternates?.filter(Boolean) || []]
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
s.sentence,
s.alternates?.filter(Boolean) || [],
s.correctSentenceFeedback || null,
s.alternateFeedbacks || [],
s.incorrectFeedbacks || [],
s.incorrectAlternates || [],
]
);
}
});
@@ -21,20 +21,31 @@ export default function JumbledSentenceExercisePage() {
const [bankWords, setBankWords] = useState([]);
const [answerWords, setAnswerWords] = useState([]);
const [sentenceResults, setSentenceResults] = useState([]);
const [sentenceFeedbacks, setSentenceFeedbacks] = useState([]);
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(true);
const cleanToken = (token) => token.replace(/[\p{P}]/gu, "").toLowerCase();
const stripIgnoredPunctuation = (token) =>
token.replace(/[\p{P}]/gu, (character) =>
character === "'" || character === "’" ? character : ""
);
const tokenizeSentence = (value) =>
value.trim().split(/\s+/).map(cleanToken).filter(Boolean);
const cleanTokenForComparison = (token) =>
stripIgnoredPunctuation(token).toLowerCase();
const normalizeSentence = (value) => tokenizeSentence(value).join(" ");
const tokenizeSentenceForDisplay = (value) =>
value.trim().split(/\s+/).map(stripIgnoredPunctuation).filter(Boolean);
const normalizeSentence = (value) =>
tokenizeSentenceForDisplay(value)
.map(cleanTokenForComparison)
.filter(Boolean)
.join(" ");
const initializeExerciseState = (exerciseData) => {
const sentenceTokenSets = (exerciseData?.sentences || []).map(
(sentence) => {
const tokens = tokenizeSentence(sentence.sentence).map(
const tokens = tokenizeSentenceForDisplay(sentence.sentence).map(
(text, index) => ({
id: `${index}-${text}`,
text,
@@ -49,6 +60,7 @@ export default function JumbledSentenceExercisePage() {
sentenceTokenSets.map((tokens) => Array(tokens.length).fill(null))
);
setSentenceResults(sentenceTokenSets.map(() => null));
setSentenceFeedbacks(sentenceTokenSets.map(() => ""));
setFeedback("");
};
@@ -59,6 +71,13 @@ export default function JumbledSentenceExercisePage() {
next[sentenceIdx] = null;
return next;
});
setSentenceFeedbacks((prev) => {
if (!prev.length || !prev[sentenceIdx]) return prev;
const next = [...prev];
next[sentenceIdx] = "";
return next;
});
};
useEffect(() => {
@@ -177,28 +196,73 @@ export default function JumbledSentenceExercisePage() {
const checkAnswers = () => {
if (!exercise) return;
const results = exercise.sentences.map((sentence, sentenceIdx) => {
const evaluation = exercise.sentences.map((sentence, sentenceIdx) => {
if (answerWords[sentenceIdx].some((slot) => slot === null)) {
return false;
return {
isCorrect: false,
feedbackText: "",
};
}
const acceptedAnswers = [
sentence.sentence,
...(sentence.alternates || []),
].map((value) => normalizeSentence(value));
const normalizedMainSentence = normalizeSentence(sentence.sentence);
const normalizedAlternates = (sentence.alternates || []).map((value) =>
normalizeSentence(value)
);
const normalizedAlternateFeedbacks = sentence.alternateFeedbacks || [];
const normalizedIncorrectAlternates = (
sentence.incorrectAlternates || []
).map((value) => normalizeSentence(value));
const normalizedIncorrectFeedbacks = sentence.incorrectFeedbacks || [];
const userAnswer = normalizeSentence(
answerWords[sentenceIdx].map((token) => token?.text || "").join(" ")
);
return acceptedAnswers.includes(userAnswer);
if (userAnswer === normalizedMainSentence) {
return {
isCorrect: true,
feedbackText: sentence.correctSentenceFeedback || "",
};
}
const alternateIndex = normalizedAlternates.findIndex(
(candidate) => candidate === userAnswer
);
if (alternateIndex !== -1) {
return {
isCorrect: true,
feedbackText: normalizedAlternateFeedbacks[alternateIndex] || "",
};
}
const incorrectAlternateIndex = normalizedIncorrectAlternates.findIndex(
(candidate) => candidate === userAnswer
);
if (incorrectAlternateIndex !== -1) {
return {
isCorrect: false,
feedbackText:
normalizedIncorrectFeedbacks[incorrectAlternateIndex] || "",
};
}
return {
isCorrect: false,
feedbackText: "",
};
});
const results = evaluation.map((entry) => entry.isCorrect);
const perSentenceFeedbacks = evaluation.map((entry) => entry.feedbackText);
setSentenceResults(results);
setSentenceFeedbacks(perSentenceFeedbacks);
const allCorrect = results.every(Boolean);
setFeedback(allCorrect ? "Richtig!" : "Leider falsch, versuche es erneut.");
setFeedback(allCorrect ? "Alle Sätze sind richtig." : "");
};
if (loading) return <div>Lädt...</div>;
@@ -215,7 +279,7 @@ export default function JumbledSentenceExercisePage() {
style={{ marginBottom: "var(--u-lg)" }}
>
<Row align="center" gap="sm">
<div>Ordne die Wörter richtig:</div>
<div>Ordnen Sie die Elemente richtig an:</div>
{sentenceResults[sentenceIdx] === true && (
<span style={{ color: "#1f9d55", fontWeight: 700 }}>Richtig</span>
)}
@@ -224,6 +288,18 @@ export default function JumbledSentenceExercisePage() {
)}
</Row>
{sentenceFeedbacks[sentenceIdx] && (
<div
style={{
color:
sentenceResults[sentenceIdx] === true ? "#1f9d55" : "#d64545",
fontWeight: 600,
}}
>
{sentenceFeedbacks[sentenceIdx]}
</div>
)}
<Row
gap="sm"
style={{
@@ -322,11 +398,7 @@ export default function JumbledSentenceExercisePage() {
</Button>
</Row>
{feedback && (
<div style={{ color: feedback === "Richtig!" ? "green" : "red" }}>
{feedback}
</div>
)}
{feedback && <div style={{ color: "#1f9d55" }}>{feedback}</div>}
</Column>
);
}
@@ -56,7 +56,7 @@ export default function JumbledSentenceExercisesPage() {
return (
<Container maxW="800px" m="0 auto" p="md">
<Container mb="lg">
<h1>Jumbled Sentence Übungen</h1>
<h1>Satzmix-Übungen</h1>
</Container>
<Container color="var(--fg4)">
Zurzeit sind keine Übungen verfügbar.
@@ -71,7 +71,7 @@ export default function JumbledSentenceExercisesPage() {
return (
<Container maxW="800px" m="0 auto" p="md">
<Container mb="lg">
<h1>Jumbled Sentence Übungen</h1>
<h1>Satzmix-Übungen</h1>
</Container>
<Container
+1 -1
View File
@@ -48,7 +48,7 @@ const exerciseTypes = [
image: "🧠",
},
{
title: "Jumbled Sentence",
title: "Satzmix-Übungen",
description: "Üben mit Satzordnungen.",
link: "/grammar/exercises/jumbled-sentence",
image: "↔️",
@@ -32,10 +32,36 @@ export const jumbledSentenceSchema = z
.map((item) => (typeof item === "string" ? item.trim() : item))
.filter((item) => typeof item === "string" && item.length > 0);
}, z.array(z.string())),
alternateFeedbacks: z.preprocess((value) => {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item.trim() : ""));
}, z.array(z.string())),
correctSentenceFeedback: z.preprocess(
(value) => (typeof value === "string" ? value.trim() : ""),
z.string()
),
incorrectFeedbacks: z.preprocess((value) => {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item.trim() : ""));
}, z.array(z.string())),
incorrectAlternates: z.preprocess((value) => {
if (!Array.isArray(value)) return [];
return value
.map((item) => (typeof item === "string" ? item.trim() : item))
.filter((item) => typeof item === "string" && item.length > 0);
}, z.array(z.string())),
})
.superRefine((data, ctx) => {
const baseTokens = normalizeTokens(data.sentence);
if (data.alternateFeedbacks.length !== data.alternates.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["alternateFeedbacks"],
message: "Jeder Alternativ-Satz kann optional eigenes Feedback haben.",
});
}
for (let index = 0; index < data.alternates.length; index++) {
const alternate = data.alternates[index];
const alternateTokens = normalizeTokens(alternate);
@@ -49,6 +75,29 @@ export const jumbledSentenceSchema = z
});
}
}
for (let index = 0; index < data.incorrectAlternates.length; index++) {
const incorrectAlternate = data.incorrectAlternates[index];
const incorrectTokens = normalizeTokens(incorrectAlternate);
if (!sameWordSet(baseTokens, incorrectTokens)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["incorrectAlternates", index],
message:
"Falsche Alternativ-Antwort muss genau die gleichen Wörter wie 'Satz (korrekt)' enthalten (nur Reihenfolge darf anders sein).",
});
}
}
if (data.incorrectFeedbacks.length !== data.incorrectAlternates.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["incorrectFeedbacks"],
message:
"Jede falsche Alternative kann optional eigenes Feedback haben.",
});
}
});
export const jumbledSentenceExerciseSchema = z.object({
+1 -3
View File
@@ -1,9 +1,7 @@
{
"status": "failed",
"failedTests": [
"18e1443b7169cebccd21-4a0da125f055d790cf05",
"cf4ffee829137744e3cd-e6406448e73a28c0ddd9",
"d936625e1207b3e19d4b-67eb4c2dd9e8efb88e3d",
"18e1443b7169cebccd21-2292e031b11e8f9b3824",
"d8918302922cfb4b5ab2-53975d1f9ee0854667fa"
]
}