diff --git a/docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md b/docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md
new file mode 100644
index 0000000..9c8dae7
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md
@@ -0,0 +1,542 @@
+# Phonics Blends Worksheets Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Generate 4 print-ready HTML phonics-blend word-list worksheets (~260 words total, ≥10% with grapheme-chunk markup) for a 3-year-old to sound out.
+
+**Architecture:** A single standalone script `scripts/generate_phonics_blends_series.py` that builds 4 HTML files in `output/phonics_blends/`, following the same pattern as existing generators (font injection, `_CSS`/`_HTML_WRAPPER` from `worksheet_html_renderer`, auto-print on load). One file per blend family (L, R, S, Final). No changes to `src/`.
+
+**Tech Stack:** Python 3, existing `src/worksheet_html_renderer._CSS/_HTML_WRAPPER`, OpenDyslexic font via absolute `file://` path, pytest for verification.
+
+---
+
+### Task 1: Write failing integration test
+
+**Files:**
+- Create: `tests/test_generate_phonics_blends.py`
+
+- [ ] **Step 1: Write the test file**
+
+```python
+# tests/test_generate_phonics_blends.py
+import os
+import subprocess
+import pytest
+
+OUTPUT_DIR = "output/phonics_blends"
+EXPECTED_FILES = [
+ "01_l_blends.html",
+ "02_r_blends.html",
+ "03_s_blends.html",
+ "04_final_blends.html",
+]
+
+
+@pytest.fixture(scope="module", autouse=True)
+def run_script():
+ result = subprocess.run(
+ ["python", "scripts/generate_phonics_blends_series.py"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, f"Script failed:\n{result.stderr}"
+
+
+def test_all_files_created():
+ for fname in EXPECTED_FILES:
+ path = os.path.join(OUTPUT_DIR, fname)
+ assert os.path.exists(path), f"Missing output file: {path}"
+
+
+def test_word_count_per_file():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ count = content.count('class="word"')
+ assert count >= 25, f"{fname}: expected >=25 words, got {count}"
+
+
+def test_grapheme_chunks_present():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ assert 'class="blend-part"' in content, f"{fname}: missing grapheme chunks"
+ chunk_count = content.count('class="blend-part"')
+ word_count = content.count('class="word"')
+ assert chunk_count / word_count >= 0.10, (
+ f"{fname}: chunk ratio {chunk_count}/{word_count} is below 10%"
+ )
+
+
+def test_opendyslexic_referenced():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ assert "OpenDyslexic" in content, f"{fname}: OpenDyslexic font not referenced"
+
+
+def test_auto_print_present():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ assert "window.print()" in content, f"{fname}: missing auto-print trigger"
+```
+
+- [ ] **Step 2: Run tests to confirm they fail (script doesn't exist yet)**
+
+```bash
+pytest tests/test_generate_phonics_blends.py -v
+```
+
+Expected: FAIL — `AssertionError: Script failed` or `FileNotFoundError` because `scripts/generate_phonics_blends_series.py` does not exist yet.
+
+- [ ] **Step 3: Commit the test**
+
+```bash
+git add tests/test_generate_phonics_blends.py
+git commit -m "test: add failing integration tests for phonics blends generator"
+```
+
+---
+
+### Task 2: Implement the generator script
+
+**Files:**
+- Create: `scripts/generate_phonics_blends_series.py`
+
+- [ ] **Step 1: Create the script with all word data and HTML builder**
+
+Create `scripts/generate_phonics_blends_series.py` with the following complete contents:
+
+```python
+"""
+generate_phonics_blends_series.py
+
+Phonics blends word-list worksheets for a 3-year-old.
+Covers L-blends, R-blends, S-blends, and final blends.
+~20% of words shown with grapheme-chunk markup to scaffold sounding-out.
+
+Output: output/phonics_blends/ (4 HTML files, one per blend family)
+
+Run from project root:
+ python scripts/generate_phonics_blends_series.py
+"""
+
+import html as _html
+import os
+import sys
+
+os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.abspath("src"))
+
+from worksheet_html_renderer import _CSS, _HTML_WRAPPER # noqa: E402
+
+# ── Font injection ──────────────────────────────────────────────────────────
+
+_FONT_FACE = """\
+ @font-face {
+ font-family: 'OpenDyslexic';
+ src: local('OpenDyslexic'),
+ url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Regular.otf') format('opentype');
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: 'OpenDyslexic';
+ src: local('OpenDyslexic Bold'),
+ url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Bold.otf') format('opentype');
+ font-weight: bold;
+ font-style: normal;
+ }
+"""
+
+# ── Word data ───────────────────────────────────────────────────────────────
+# Each blend entry: blend prefix/suffix, 10 words, 2 words to show chunked.
+# For initial blends (final=False): split after len(blend) chars → bl·ue
+# For final blends (final=True): split before last len(blend) chars → ha·nd
+
+FAMILIES = [
+ {
+ "filename": "01_l_blends.html",
+ "title": "L-Blends",
+ "subtitle": "bl · cl · fl · gl · pl · sl",
+ "accent": "#1d4ed8",
+ "bg": "#dbeafe",
+ "final": False,
+ "blends": [
+ {
+ "blend": "bl",
+ "words": ["blue", "black", "blob", "blot", "blab", "blip", "blaze", "block", "blow", "blend"],
+ "chunks": ["blot", "blob"],
+ },
+ {
+ "blend": "cl",
+ "words": ["clap", "clay", "clip", "clock", "clown", "club", "clean", "clog", "cluck", "clan"],
+ "chunks": ["clap", "clip"],
+ },
+ {
+ "blend": "fl",
+ "words": ["flag", "flat", "flip", "flock", "fly", "flap", "flab", "flit", "fled", "flog"],
+ "chunks": ["flip", "flit"],
+ },
+ {
+ "blend": "gl",
+ "words": ["glad", "glass", "glide", "glow", "glue", "glob", "glen", "glum", "glint", "glee"],
+ "chunks": ["glad", "glob"],
+ },
+ {
+ "blend": "pl",
+ "words": ["plan", "play", "plop", "plug", "plus", "plum", "plot", "plod", "plank", "pluck"],
+ "chunks": ["plop", "plug"],
+ },
+ {
+ "blend": "sl",
+ "words": ["slap", "slam", "slip", "slob", "slug", "sled", "slim", "slot", "slid", "slop"],
+ "chunks": ["slap", "slid"],
+ },
+ ],
+ },
+ {
+ "filename": "02_r_blends.html",
+ "title": "R-Blends",
+ "subtitle": "br · cr · dr · fr · gr · pr · tr",
+ "accent": "#15803d",
+ "bg": "#dcfce7",
+ "final": False,
+ "blends": [
+ {
+ "blend": "br",
+ "words": ["brag", "brick", "brush", "brown", "bring", "brim", "bred", "brat", "brisk", "brew"],
+ "chunks": ["brat", "brim"],
+ },
+ {
+ "blend": "cr",
+ "words": ["crab", "crack", "crop", "crow", "crush", "crib", "crisp", "cram", "crest", "crag"],
+ "chunks": ["crab", "cram"],
+ },
+ {
+ "blend": "dr",
+ "words": ["drag", "drip", "drop", "drum", "drub", "drab", "drift", "drill", "dress", "drew"],
+ "chunks": ["drip", "drop"],
+ },
+ {
+ "blend": "fr",
+ "words": ["frog", "frost", "from", "fresh", "fry", "frill", "fret", "frisk", "frock", "franc"],
+ "chunks": ["frog", "fret"],
+ },
+ {
+ "blend": "gr",
+ "words": ["grab", "grass", "gray", "grin", "grip", "grub", "grit", "gram", "grim", "grew"],
+ "chunks": ["grin", "grub"],
+ },
+ {
+ "blend": "pr",
+ "words": ["press", "prim", "prop", "prod", "prom", "prank", "prep", "prig", "prism", "prone"],
+ "chunks": ["prim", "prop"],
+ },
+ {
+ "blend": "tr",
+ "words": ["trap", "tree", "trip", "trot", "truck", "trim", "track", "tram", "trek", "trick"],
+ "chunks": ["trip", "trot"],
+ },
+ ],
+ },
+ {
+ "filename": "03_s_blends.html",
+ "title": "S-Blends",
+ "subtitle": "sc · sk · sm · sn · sp · st · sw",
+ "accent": "#7c3aed",
+ "bg": "#ede9fe",
+ "final": False,
+ "blends": [
+ {
+ "blend": "sc",
+ "words": ["scam", "scat", "scab", "scar", "scan", "scoff", "scold", "scone", "scope", "scorn"],
+ "chunks": ["scam", "scab"],
+ },
+ {
+ "blend": "sk",
+ "words": ["skip", "skill", "skin", "sky", "skim", "skid", "skull", "sketch", "skunk", "skit"],
+ "chunks": ["skip", "skid"],
+ },
+ {
+ "blend": "sm",
+ "words": ["small", "smash", "smell", "smile", "smoke", "smock", "smug", "smart", "smear", "smirk"],
+ "chunks": ["smug", "smash"],
+ },
+ {
+ "blend": "sn",
+ "words": ["snag", "snap", "sniff", "snob", "snow", "snug", "sneak", "snore", "snip", "snarl"],
+ "chunks": ["snap", "snip"],
+ },
+ {
+ "blend": "sp",
+ "words": ["span", "spin", "spit", "spot", "spur", "spell", "spill", "spoke", "sport", "speck"],
+ "chunks": ["spin", "spot"],
+ },
+ {
+ "blend": "st",
+ "words": ["stop", "step", "stem", "star", "stir", "stamp", "stone", "store", "stab", "stuck"],
+ "chunks": ["stop", "stem"],
+ },
+ {
+ "blend": "sw",
+ "words": ["swap", "swim", "swing", "sweet", "swept", "swell", "swift", "swab", "swipe", "swam"],
+ "chunks": ["swim", "swam"],
+ },
+ ],
+ },
+ {
+ "filename": "04_final_blends.html",
+ "title": "Final Blends",
+ "subtitle": "nd · nt · st · sk · lk · mp",
+ "accent": "#c2410c",
+ "bg": "#ffedd5",
+ "final": True,
+ "blends": [
+ {
+ "blend": "nd",
+ "words": ["hand", "band", "wind", "bond", "bend", "find", "land", "mind", "sand", "end"],
+ "chunks": ["hand", "bend"],
+ },
+ {
+ "blend": "nt",
+ "words": ["mint", "hint", "rent", "hunt", "tent", "dent", "pant", "punt", "font", "rant"],
+ "chunks": ["mint", "tent"],
+ },
+ {
+ "blend": "st",
+ "words": ["best", "fast", "list", "most", "past", "rest", "dust", "fist", "mist", "last"],
+ "chunks": ["best", "fast"],
+ },
+ {
+ "blend": "sk",
+ "words": ["ask", "desk", "dusk", "husk", "mask", "risk", "task", "brisk", "flask", "disk"],
+ "chunks": ["desk", "mask"],
+ },
+ {
+ "blend": "lk",
+ "words": ["bulk", "hulk", "milk", "silk", "sulk", "talk", "walk", "elk", "folk", "yolk"],
+ "chunks": ["milk", "bulk"],
+ },
+ {
+ "blend": "mp",
+ "words": ["bump", "camp", "damp", "dump", "jump", "lamp", "limp", "pump", "ramp", "hemp"],
+ "chunks": ["jump", "bump"],
+ },
+ ],
+ },
+]
+
+# ── HTML helpers ────────────────────────────────────────────────────────────
+
+
+def _word_html(word: str, blend: str, is_final: bool, accent: str, is_chunk: bool) -> str:
+ w = _html.escape(word)
+ if not is_chunk:
+ return f'{w} '
+ n = len(blend)
+ if is_final:
+ root = _html.escape(word[:-n])
+ blend_part = _html.escape(word[-n:])
+ return (
+ f''
+ f'{root} '
+ f'· '
+ f'{blend_part} '
+ f' '
+ )
+ blend_part = _html.escape(word[:n])
+ rest = _html.escape(word[n:])
+ return (
+ f''
+ f'{blend_part} '
+ f'· '
+ f'{rest} '
+ f' '
+ )
+
+
+def _build_family_page(family: dict) -> str:
+ accent = family["accent"]
+ bg = family["bg"]
+ is_final = family["final"]
+
+ css = _CSS.replace("", extra_css + " ")
+
+ sections = []
+ for blend_info in family["blends"]:
+ blend = blend_info["blend"]
+ chunk_set = set(blend_info["chunks"])
+ words_html = "\n ".join(
+ _word_html(w, blend, is_final, accent, w in chunk_set)
+ for w in blend_info["words"]
+ )
+ sections.append(
+ f'
\n'
+ f'
{_html.escape(blend)} words
\n'
+ f'
\n {words_html}\n
\n'
+ f'
'
+ )
+
+ body = (
+ '\n'
+ f' \n'
+ + "\n".join(sections)
+ + '\n
'
+ + '\n Name: '
+ + " " * 20
+ + " "
+ + '\n Date: '
+ + " " * 20
+ + " "
+ + "\n
"
+ + "\n
"
+ + '\n'
+ )
+
+ return _HTML_WRAPPER.format(
+ title=_html.escape(family["title"]),
+ css=css,
+ body=body,
+ )
+
+
+# ── Entry point ─────────────────────────────────────────────────────────────
+
+
+def main() -> None:
+ out_dir = os.path.join("output", "phonics_blends")
+ os.makedirs(out_dir, exist_ok=True)
+ for family in FAMILIES:
+ html = _build_family_page(family)
+ path = os.path.join(out_dir, family["filename"])
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(html)
+ print(f"Wrote {path}")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+- [ ] **Step 2: Run the script manually to check it executes without errors**
+
+```bash
+python scripts/generate_phonics_blends_series.py
+```
+
+Expected output:
+```
+Wrote output/phonics_blends/01_l_blends.html
+Wrote output/phonics_blends/02_r_blends.html
+Wrote output/phonics_blends/03_s_blends.html
+Wrote output/phonics_blends/04_final_blends.html
+```
+
+If it errors, fix before proceeding.
+
+---
+
+### Task 3: Run tests, verify, and commit
+
+- [ ] **Step 1: Run the full test suite**
+
+```bash
+pytest tests/test_generate_phonics_blends.py -v
+```
+
+Expected: All 5 tests PASS.
+- `test_all_files_created` — 4 files exist
+- `test_word_count_per_file` — ≥25 `class="word"` per file
+- `test_grapheme_chunks_present` — ≥10% chunk ratio per file
+- `test_opendyslexic_referenced` — font referenced in every file
+- `test_auto_print_present` — `window.print()` in every file
+
+If any test fails, fix the script and re-run.
+
+- [ ] **Step 2: Spot-check one file visually (optional but recommended)**
+
+```bash
+# Open in browser to verify layout looks correct
+xdg-open output/phonics_blends/01_l_blends.html
+```
+
+Verify: OpenDyslexic font loads, words display in 3-column grid at large size, grapheme-chunked words show blend in accent color with `·` separator, header bar correct color.
+
+- [ ] **Step 3: Commit everything**
+
+```bash
+git add scripts/generate_phonics_blends_series.py tests/test_generate_phonics_blends.py output/phonics_blends/
+git commit -m "feat: add phonics blends word-list worksheet generator
+
+260 words across 4 blend families (L, R, S, Final).
+~20% of words displayed with grapheme-chunk markup.
+OpenDyslexic font, letter-size, auto-print on load."
+```
diff --git a/docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md b/docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md
new file mode 100644
index 0000000..dcb1c29
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md
@@ -0,0 +1,82 @@
+# Phonics Blends Worksheets — Design Spec
+
+**Date:** 2026-05-19
+**Status:** Approved
+
+## Summary
+
+A standalone Python script that generates 4 print-ready HTML worksheet files covering phonics blends. Intended for a 3-year-old learning to sound out words. No interactive activities — each page is a word list to read aloud, with a subset of words broken into grapheme chunks to scaffold sounding-out.
+
+## Output Files
+
+All files written to `output/phonics_blends/`:
+
+| File | Blend Family | Blends Covered | Target Word Count |
+|------|-------------|----------------|-------------------|
+| `01_l_blends.html` | L-blends | bl, cl, fl, gl, pl, sl | ~60 |
+| `02_r_blends.html` | R-blends | br, cr, dr, fr, gr, pr, tr | ~70 |
+| `03_s_blends.html` | S-blends | sc, sk, sm, sn, sp, st, sw | ~70 |
+| `04_final_blends.html` | Final blends | nd, nt, st, sk, lk, mp | ~60 |
+
+**Total: ~260 words. At least 10% (~26) displayed with grapheme chunks.**
+
+## Page Layout
+
+Each HTML file follows the existing project pattern:
+
+- **Font:** OpenDyslexic (primary), Trebuchet MS / Arial fallback
+- **Font size:** 20pt for words, 13pt for sub-headers
+- **Page size:** US Letter, 0.45in top/bottom margins, 0.5in left/right margins
+- **Auto-print:** `window.addEventListener("load", () => window.print())` trigger
+- **Color scheme:** One accent color per blend family, drawn from the existing day palette:
+ - L-blends → Blue (`#1d4ed8` / `#dbeafe`)
+ - R-blends → Green (`#15803d` / `#dcfce7`)
+ - S-blends → Purple (`#7c3aed` / `#ede9fe`)
+ - Final blends → Orange (`#c2410c` / `#ffedd5`)
+
+**Page structure (top to bottom):**
+1. Full-width color header bar — blend family name (e.g., "L-Blends") + subtitle listing the blends covered (e.g., "bl · cl · fl · gl · pl · sl")
+2. For each blend in the family: a sub-header (e.g., "bl words") followed by its word list
+3. Words displayed in a 3-column CSS grid
+4. Name line + date line at the bottom of each page (standard across all worksheets)
+
+## Grapheme Chunk Format
+
+Approximately 2–3 words per blend section are shown with the blend chunk visually separated:
+
+```
+bl · ue cr · ab st · op
+```
+
+- The blend letters are rendered in the page's accent color, bold weight
+- The separator `·` (U+00B7 middle dot) is in a muted gray
+- The remainder of the word is normal weight, black
+- Regular (non-chunked) words are displayed as plain text at the same size
+
+Chunked words are chosen to be short and decodable (CVC+blend structure preferred), e.g. `bl·ot`, `cr·ab`, `st·op` rather than complex vowel patterns.
+
+## Script Structure
+
+**File:** `scripts/generate_phonics_blends_series.py`
+
+Follows the pattern of existing generators (e.g., `generate_mancala_math_series.py`):
+
+1. Embed OpenDyslexic `@font-face` CSS block at the top of the script
+2. Define word lists per blend as Python dicts — `{blend: [words]}` — with a separate dict marking which words get grapheme-chunk display and where the split point is
+3. For each blend family, call a `_build_page_html(family_name, blends_dict, palette)` helper that assembles the full HTML document
+4. Write each file to `output/phonics_blends/`
+5. Print confirmation for each file written
+
+## Word Selection Constraints
+
+- All words must be real, common English words a 3-year-old would recognise or can be sounded out
+- Prefer CVC or CCVC structure (short vowels) for accessibility
+- Avoid multi-syllable words except where the blend is very clear (e.g., "blanket")
+- Final blends: ensure words are monosyllabic where possible (hand, mint, desk)
+
+## Out of Scope
+
+- No images or picture matching
+- No tracing lines or write-in activities
+- No integration with the FastAPI backend or database
+- No frontend UI changes
diff --git a/frontend/app/plans/page.tsx b/frontend/app/plans/page.tsx
index 9c390c1..19f2875 100644
--- a/frontend/app/plans/page.tsx
+++ b/frontend/app/plans/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useMemo, useCallback } from 'react';
+import { useState, useMemo, useCallback, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Card, Button, Badge, Modal } from '@/components/ui';
import { Navigation } from '@/components/Navigation';
@@ -21,6 +21,23 @@ const QUANTITY_RATING_MAP: Record = {
TOO_MUCH: -2,
};
+// Reverse map: backend integer to UI rating label
+function quantityToRating(qty: number): string {
+ if (qty > 0) return 'TOO_LITTLE';
+ if (qty < 0) return 'TOO_MUCH';
+ return 'JUST_RIGHT';
+}
+
+const FEEDBACK_LOCK_WEEKS = 3;
+
+function isFeedbackLocked(feedbackCompletedAt: string | null): boolean {
+ if (!feedbackCompletedAt) return false;
+ const submitted = new Date(feedbackCompletedAt);
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - FEEDBACK_LOCK_WEEKS * 7);
+ return submitted < cutoff;
+}
+
export default function PlansPage() {
const { data: students } = useStudents();
const { packets: pendingPackets, isLoading: pendingLoading } = usePendingPackets();
@@ -42,6 +59,40 @@ export default function PlansPage() {
const queryClient = useQueryClient();
+ // Helper to find the selected packet from lists
+ const selectedPacket = useMemo(() => {
+ if (!selectedPacketIds) return null;
+ const allPackets = [...pendingPackets, ...completedPackets];
+ return allPackets.find(
+ (p) =>
+ p.student_id === selectedPacketIds.studentId && p.packet_id === selectedPacketIds.packetId
+ );
+ }, [selectedPacketIds, pendingPackets, completedPackets]);
+
+ // Fetch existing feedback when the feedback modal is open for a packet that has feedback
+ const { data: existingFeedback } = useQuery({
+ queryKey: ['packet-feedback', selectedPacketIds?.studentId, selectedPacketIds?.packetId],
+ queryFn: async () => {
+ if (!selectedPacketIds) return null;
+ return await plansApi.getFeedback(selectedPacketIds.studentId, selectedPacketIds.packetId);
+ },
+ enabled: !!selectedPacketIds && feedbackModalOpen && !!selectedPacket?.has_feedback,
+ });
+
+ // Pre-populate feedback modal with existing values when editing
+ useEffect(() => {
+ if (existingFeedback && feedbackModalOpen && masteryRating === null) {
+ const mastery = existingFeedback.mastery_feedback?.overall ?? null;
+ setMasteryRating(mastery);
+ if (
+ existingFeedback.quantity_feedback !== null &&
+ existingFeedback.quantity_feedback !== undefined
+ ) {
+ setQuantityRating(quantityToRating(existingFeedback.quantity_feedback));
+ }
+ }
+ }, [existingFeedback, feedbackModalOpen, masteryRating]);
+
// Fetch plan detail when packet is selected
const { data: planDetail, isLoading: planDetailLoading } = useQuery({
queryKey: ['plan-detail', selectedPacketIds?.studentId, selectedPacketIds?.packetId],
@@ -99,16 +150,6 @@ export default function PlansPage() {
const { mutate: submitFeedback } = feedbackMutation;
- // Helper to find the selected packet from lists
- const selectedPacket = useMemo(() => {
- if (!selectedPacketIds) return null;
- const allPackets = [...pendingPackets, ...completedPackets];
- return allPackets.find(
- (p) =>
- p.student_id === selectedPacketIds.studentId && p.packet_id === selectedPacketIds.packetId
- );
- }, [selectedPacketIds, pendingPackets, completedPackets]);
-
const handleViewPlan = useCallback((packet: WeeklyPacketWithStudent) => {
setSelectedPacketIds({
studentId: packet.student_id,
@@ -522,14 +563,19 @@ export default function PlansPage() {
Print All
)}
- {selectedPacket.status === 'ready' && (
-
- Provide Feedback
-
- )}
+ {selectedPacket.status === 'ready' &&
+ (isFeedbackLocked(selectedPacket.feedback_completed_at) ? (
+
+ Feedback Submitted
+
+ ) : (
+
+ {selectedPacket.has_feedback ? 'Edit Feedback' : 'Provide Feedback'}
+
+ ))}
@@ -543,12 +589,13 @@ export default function PlansPage() {
setMasteryRating(null);
setQuantityRating(null);
}}
- title="Provide Feedback"
+ title={selectedPacket.has_feedback ? 'Edit Feedback' : 'Provide Feedback'}
>
- Help the AI understand how {selectedPacket.studentName} did with this week's
- plan.
+ {selectedPacket.has_feedback
+ ? `Update how ${selectedPacket.studentName} did with this week’s plan.`
+ : `Help the AI understand how ${selectedPacket.studentName} did with this week’s plan.`}
{/* Mastery Rating */}
@@ -630,7 +677,11 @@ export default function PlansPage() {
onClick={handleSubmitFeedback}
disabled={!masteryRating || !quantityRating || feedbackMutation.isPending}
>
- {feedbackMutation.isPending ? 'Submitting...' : 'Submit Feedback'}
+ {feedbackMutation.isPending
+ ? 'Submitting...'
+ : selectedPacket.has_feedback
+ ? 'Update Feedback'
+ : 'Submit Feedback'}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index d92b097..4d68051 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -44,6 +44,14 @@ export interface WeeklyPacketSummary {
resource_days: number;
daily_count: number;
updated_at: string;
+ has_feedback: boolean;
+ feedback_completed_at: string | null;
+}
+
+export interface FeedbackData {
+ mastery_feedback: Record | null;
+ quantity_feedback: number | null;
+ completed_at: string | null;
}
export interface PaginatedResponse {
@@ -196,6 +204,17 @@ export const plansApi = {
return data;
},
+ getFeedback: async (studentId: string, packetId: string): Promise => {
+ try {
+ const { data } = await apiClient.get(
+ `/students/${studentId}/weekly-packets/${packetId}/feedback`
+ );
+ return data;
+ } catch {
+ return null;
+ }
+ },
+
submitFeedback: async (
studentId: string,
packetId: string,
diff --git a/scripts/generate_phonics_blends_series.py b/scripts/generate_phonics_blends_series.py
new file mode 100644
index 0000000..47af0e2
--- /dev/null
+++ b/scripts/generate_phonics_blends_series.py
@@ -0,0 +1,664 @@
+"""
+generate_phonics_blends_series.py
+
+Phonics blends word-list worksheets for a 3-year-old.
+Covers L-blends, R-blends, S-blends, and final blends.
+~20% of words shown with grapheme-chunk markup to scaffold sounding-out.
+
+Output: output/phonics_blends/ (4 HTML files, one per blend family)
+
+Run from project root:
+ python scripts/generate_phonics_blends_series.py
+"""
+
+import html as _html
+import os
+import sys
+
+os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.abspath("src"))
+
+from worksheet_html_renderer import _CSS, _HTML_WRAPPER # noqa: E402
+
+# ── Font injection ──────────────────────────────────────────────────────────
+
+_FONT_FACE = """\
+ @font-face {
+ font-family: 'OpenDyslexic';
+ src: local('OpenDyslexic'),
+ url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Regular.otf') format('opentype');
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: 'OpenDyslexic';
+ src: local('OpenDyslexic Bold'),
+ url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Bold.otf') format('opentype');
+ font-weight: bold;
+ font-style: normal;
+ }
+"""
+
+# ── Word data ───────────────────────────────────────────────────────────────
+# Each blend entry: blend prefix/suffix, 10 words, 2 words to show chunked.
+# For initial blends (final=False): split after len(blend) chars → bl·ue
+# For final blends (final=True): split before last len(blend) chars → ha·nd
+
+FAMILIES = [
+ {
+ "filename": "01_l_blends.html",
+ "title": "L-Blends",
+ "subtitle": "bl · cl · fl · gl · pl · sl",
+ "accent": "#1d4ed8",
+ "bg": "#dbeafe",
+ "final": False,
+ "blends": [
+ {
+ "blend": "bl",
+ "words": [
+ "blue",
+ "black",
+ "blob",
+ "blot",
+ "blab",
+ "blip",
+ "blaze",
+ "block",
+ "blow",
+ "blend",
+ ],
+ "chunks": ["blot", "blob"],
+ },
+ {
+ "blend": "cl",
+ "words": [
+ "clap",
+ "clay",
+ "clip",
+ "clock",
+ "clown",
+ "club",
+ "clean",
+ "clog",
+ "cluck",
+ "clan",
+ ],
+ "chunks": ["clap", "clip"],
+ },
+ {
+ "blend": "fl",
+ "words": [
+ "flag",
+ "flat",
+ "flip",
+ "flock",
+ "fly",
+ "flap",
+ "flab",
+ "flit",
+ "fled",
+ "flog",
+ ],
+ "chunks": ["flip", "flit"],
+ },
+ {
+ "blend": "gl",
+ "words": [
+ "glad",
+ "glass",
+ "glide",
+ "glow",
+ "glue",
+ "glob",
+ "glen",
+ "glum",
+ "glint",
+ "glee",
+ ],
+ "chunks": ["glad", "glob"],
+ },
+ {
+ "blend": "pl",
+ "words": [
+ "plan",
+ "play",
+ "plop",
+ "plug",
+ "plus",
+ "plum",
+ "plot",
+ "plod",
+ "plank",
+ "pluck",
+ ],
+ "chunks": ["plop", "plug"],
+ },
+ {
+ "blend": "sl",
+ "words": [
+ "slap",
+ "slam",
+ "slip",
+ "slob",
+ "slug",
+ "sled",
+ "slim",
+ "slot",
+ "slid",
+ "slop",
+ ],
+ "chunks": ["slap", "slid"],
+ },
+ ],
+ },
+ {
+ "filename": "02_r_blends.html",
+ "title": "R-Blends",
+ "subtitle": "br · cr · dr · fr · gr · pr · tr",
+ "accent": "#15803d",
+ "bg": "#dcfce7",
+ "final": False,
+ "blends": [
+ {
+ "blend": "br",
+ "words": [
+ "brag",
+ "brick",
+ "brush",
+ "brown",
+ "bring",
+ "brim",
+ "bred",
+ "brat",
+ "brisk",
+ "brew",
+ ],
+ "chunks": ["brat", "brim"],
+ },
+ {
+ "blend": "cr",
+ "words": [
+ "crab",
+ "crack",
+ "crop",
+ "crow",
+ "crush",
+ "crib",
+ "crisp",
+ "cram",
+ "crest",
+ "crag",
+ ],
+ "chunks": ["crab", "cram"],
+ },
+ {
+ "blend": "dr",
+ "words": [
+ "drag",
+ "drip",
+ "drop",
+ "drum",
+ "drub",
+ "drab",
+ "drift",
+ "drill",
+ "dress",
+ "drew",
+ ],
+ "chunks": ["drip", "drop"],
+ },
+ {
+ "blend": "fr",
+ "words": [
+ "frog",
+ "frost",
+ "from",
+ "fresh",
+ "fry",
+ "frill",
+ "fret",
+ "frisk",
+ "frock",
+ "franc",
+ ],
+ "chunks": ["frog", "fret"],
+ },
+ {
+ "blend": "gr",
+ "words": [
+ "grab",
+ "grass",
+ "gray",
+ "grin",
+ "grip",
+ "grub",
+ "grit",
+ "gram",
+ "grim",
+ "grew",
+ ],
+ "chunks": ["grin", "grub"],
+ },
+ {
+ "blend": "pr",
+ "words": [
+ "press",
+ "prim",
+ "prop",
+ "prod",
+ "prom",
+ "prank",
+ "prep",
+ "prig",
+ "prism",
+ "prone",
+ ],
+ "chunks": ["prim", "prop"],
+ },
+ {
+ "blend": "tr",
+ "words": [
+ "trap",
+ "tree",
+ "trip",
+ "trot",
+ "truck",
+ "trim",
+ "track",
+ "tram",
+ "trek",
+ "trick",
+ ],
+ "chunks": ["trip", "trot"],
+ },
+ ],
+ },
+ {
+ "filename": "03_s_blends.html",
+ "title": "S-Blends",
+ "subtitle": "sc · sk · sm · sn · sp · st · sw",
+ "accent": "#7c3aed",
+ "bg": "#ede9fe",
+ "final": False,
+ "blends": [
+ {
+ "blend": "sc",
+ "words": [
+ "scam",
+ "scat",
+ "scab",
+ "scar",
+ "scan",
+ "scoff",
+ "scold",
+ "scone",
+ "scope",
+ "scorn",
+ ],
+ "chunks": ["scam", "scab"],
+ },
+ {
+ "blend": "sk",
+ "words": [
+ "skip",
+ "skill",
+ "skin",
+ "sky",
+ "skim",
+ "skid",
+ "skull",
+ "sketch",
+ "skunk",
+ "skit",
+ ],
+ "chunks": ["skip", "skid"],
+ },
+ {
+ "blend": "sm",
+ "words": [
+ "small",
+ "smash",
+ "smell",
+ "smile",
+ "smoke",
+ "smock",
+ "smug",
+ "smart",
+ "smear",
+ "smirk",
+ ],
+ "chunks": ["smug", "smash"],
+ },
+ {
+ "blend": "sn",
+ "words": [
+ "snag",
+ "snap",
+ "sniff",
+ "snob",
+ "snow",
+ "snug",
+ "sneak",
+ "snore",
+ "snip",
+ "snarl",
+ ],
+ "chunks": ["snap", "snip"],
+ },
+ {
+ "blend": "sp",
+ "words": [
+ "span",
+ "spin",
+ "spit",
+ "spot",
+ "spur",
+ "spell",
+ "spill",
+ "spoke",
+ "sport",
+ "speck",
+ ],
+ "chunks": ["spin", "spot"],
+ },
+ {
+ "blend": "st",
+ "words": [
+ "stop",
+ "step",
+ "stem",
+ "star",
+ "stir",
+ "stamp",
+ "stone",
+ "store",
+ "stab",
+ "stuck",
+ ],
+ "chunks": ["stop", "stem"],
+ },
+ {
+ "blend": "sw",
+ "words": [
+ "swap",
+ "swim",
+ "swing",
+ "sweet",
+ "swept",
+ "swell",
+ "swift",
+ "swab",
+ "swipe",
+ "swam",
+ ],
+ "chunks": ["swim", "swam"],
+ },
+ ],
+ },
+ {
+ "filename": "04_final_blends.html",
+ "title": "Final Blends",
+ "subtitle": "nd · nt · st · sk · lk · mp",
+ "accent": "#c2410c",
+ "bg": "#ffedd5",
+ "final": True,
+ "blends": [
+ {
+ "blend": "nd",
+ "words": [
+ "hand",
+ "band",
+ "wind",
+ "bond",
+ "bend",
+ "find",
+ "land",
+ "mind",
+ "sand",
+ "end",
+ ],
+ "chunks": ["hand", "bend"],
+ },
+ {
+ "blend": "nt",
+ "words": [
+ "mint",
+ "hint",
+ "rent",
+ "hunt",
+ "tent",
+ "dent",
+ "pant",
+ "punt",
+ "font",
+ "rant",
+ ],
+ "chunks": ["mint", "tent"],
+ },
+ {
+ "blend": "st",
+ "words": [
+ "best",
+ "fast",
+ "list",
+ "most",
+ "past",
+ "rest",
+ "dust",
+ "fist",
+ "mist",
+ "last",
+ ],
+ "chunks": ["best", "fast"],
+ },
+ {
+ "blend": "sk",
+ "words": [
+ "ask",
+ "desk",
+ "dusk",
+ "husk",
+ "mask",
+ "risk",
+ "task",
+ "brisk",
+ "flask",
+ "disk",
+ ],
+ "chunks": ["desk", "mask"],
+ },
+ {
+ "blend": "lk",
+ "words": [
+ "bulk",
+ "hulk",
+ "milk",
+ "silk",
+ "sulk",
+ "talk",
+ "walk",
+ "elk",
+ "folk",
+ "yolk",
+ ],
+ "chunks": ["milk", "bulk"],
+ },
+ {
+ "blend": "mp",
+ "words": [
+ "bump",
+ "camp",
+ "damp",
+ "dump",
+ "jump",
+ "lamp",
+ "limp",
+ "pump",
+ "ramp",
+ "hemp",
+ ],
+ "chunks": ["jump", "bump"],
+ },
+ ],
+ },
+]
+
+# ── HTML helpers ────────────────────────────────────────────────────────────
+
+
+def _word_html(word: str, blend: str, is_final: bool, accent: str, is_chunk: bool) -> str:
+ w = _html.escape(word)
+ if not is_chunk:
+ return f'{w} '
+ n = len(blend)
+ if is_final:
+ root = _html.escape(word[:-n])
+ blend_part = _html.escape(word[-n:])
+ return (
+ f''
+ f'{root} '
+ f'· '
+ f'{blend_part} '
+ f" "
+ )
+ blend_part = _html.escape(word[:n])
+ rest = _html.escape(word[n:])
+ return (
+ f''
+ f'{blend_part} '
+ f'· '
+ f'{rest} '
+ f" "
+ )
+
+
+def _build_family_page(family: dict) -> str:
+ accent = family["accent"]
+ bg = family["bg"]
+ is_final = family["final"]
+
+ css = _CSS.replace("", extra_css + " ")
+
+ sections = []
+ for blend_info in family["blends"]:
+ blend = blend_info["blend"]
+ chunk_set = set(blend_info["chunks"])
+ words_html = "\n ".join(
+ _word_html(w, blend, is_final, accent, w in chunk_set) for w in blend_info["words"]
+ )
+ sections.append(
+ f' \n'
+ f'
{_html.escape(blend)} words
\n'
+ f'
\n {words_html}\n
\n'
+ f"
"
+ )
+
+ body = (
+ '\n'
+ f' \n"
+ + "\n".join(sections)
+ + '\n
'
+ + '\n Name: '
+ + " " * 20
+ + " "
+ + '\n Date: '
+ + " " * 20
+ + " "
+ + "\n
"
+ + "\n
"
+ + '\n'
+ )
+
+ return _HTML_WRAPPER.format(
+ title=_html.escape(family["title"]),
+ css=css,
+ body=body,
+ )
+
+
+# ── Entry point ─────────────────────────────────────────────────────────────
+
+
+def main() -> None:
+ out_dir = os.path.join("output", "phonics_blends")
+ os.makedirs(out_dir, exist_ok=True)
+ for family in FAMILIES:
+ html = _build_family_page(family)
+ path = os.path.join(out_dir, family["filename"])
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(html)
+ print(f"Wrote {path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/feedback_processor.py b/src/feedback_processor.py
index 1abb9a4..18fe849 100644
--- a/src/feedback_processor.py
+++ b/src/feedback_processor.py
@@ -174,6 +174,43 @@ def process_quantity_feedback(
return json.dumps(plan_rules)
+def reverse_quantity_feedback(plan_rules_blob: str, old_quantity_feedback: int) -> str:
+ """
+ Reverse a previously applied quantity feedback from plan_rules_blob.
+
+ Used when updating existing feedback to undo the old effect before applying the new one.
+
+ Args:
+ plan_rules_blob: JSON string of current plan rules
+ old_quantity_feedback: The previously submitted quantity feedback integer to reverse
+
+ Returns:
+ Updated plan_rules_blob with old feedback effect removed
+ """
+ plan_rules = json.loads(plan_rules_blob)
+
+ if "quantity_preferences" not in plan_rules:
+ return plan_rules_blob
+
+ prefs = plan_rules["quantity_preferences"]
+ current_bias = prefs.get("activity_bias", 0.0)
+
+ if old_quantity_feedback == -2:
+ current_bias += 0.3
+ elif old_quantity_feedback == -1:
+ current_bias += 0.15
+ elif old_quantity_feedback == 0:
+ # Reverse the *0.9 decay by dividing; safe because bias could be 0
+ current_bias = current_bias / 0.9 if current_bias != 0 else 0.0
+ elif old_quantity_feedback == 1:
+ current_bias -= 0.15
+ elif old_quantity_feedback == 2:
+ current_bias -= 0.3
+
+ prefs["activity_bias"] = max(-ACTIVITY_BIAS_CLAMP, min(current_bias, ACTIVITY_BIAS_CLAMP))
+ return json.dumps(plan_rules)
+
+
def is_standard_eligible(standard_metadata: dict[str, Any], reference_date: str = None) -> bool:
"""
Check if a standard is eligible to appear in a lesson based on cooldown.
diff --git a/src/main.py b/src/main.py
index fdee2e7..4419aee 100644
--- a/src/main.py
+++ b/src/main.py
@@ -25,6 +25,7 @@
from feedback_processor import (
process_mastery_feedback,
process_quantity_feedback,
+ reverse_quantity_feedback,
validate_mastery_feedback,
validate_quantity_feedback,
)
@@ -461,6 +462,8 @@ def _media_type(file_format: str | None) -> str:
return "application/pdf"
if normalized in {"png", "image/png"}:
return "image/png"
+ if normalized in {"html", "text/html"}:
+ return "text/html"
return "application/octet-stream"
@@ -546,7 +549,11 @@ def download_worksheet_artifact(student_id: str, artifact_id: int):
raise HTTPException(status_code=410, detail="Artifact unavailable")
headers = _artifact_headers(artifact)
- headers["Content-Disposition"] = f'attachment; filename="{file_path.name}"'
+ fmt = (artifact.get("file_format") or "").lower()
+ if fmt in {"html", "text/html"}:
+ headers["Content-Disposition"] = f'inline; filename="{file_path.name}"'
+ else:
+ headers["Content-Disposition"] = f'attachment; filename="{file_path.name}"'
logger.info(
"worksheet_artifact_download",
@@ -581,36 +588,29 @@ def print_weekly_packet(student_id: str, packet_id: str):
if packet is None:
raise HTTPException(status_code=404, detail="Packet not found")
- daily_plan = packet.get("daily_plan", [])
- pages: list[tuple[str, str]] = []
+ artifacts = list_packet_artifacts(student_id, packet_id)
+ if artifacts is None:
+ raise HTTPException(status_code=404, detail="Packet not found")
+
+ import re as _re
- for day in daily_plan:
- day_label = day.get("day", "")
- resources = day.get("resources") or {}
-
- for _kind, payload in resources.items():
- if not isinstance(payload, dict):
- continue
- artifacts = payload.get("artifacts", [])
- # Find the HTML artifact path for this worksheet
- html_artifact = next((a for a in artifacts if a.get("type") == "html"), None)
- if html_artifact:
- artifact_path = _resolve_artifact_path(html_artifact.get("path", ""))
- if artifact_path.exists():
- try:
- fragment = artifact_path.read_text(encoding="utf-8")
- # Strip the outer html/head/body wrapper if present so we
- # can embed the fragment directly into the print document.
- import re as _re
-
- body_match = _re.search(
- r"]*>(.*)", fragment, _re.DOTALL | _re.IGNORECASE
- )
- if body_match:
- fragment = body_match.group(1)
- pages.append((day_label, fragment))
- except OSError:
- pass
+ pages: list[tuple[str, str]] = []
+ for artifact in artifacts:
+ if (artifact.get("file_format") or "").lower() != "html":
+ continue
+ artifact_path = _resolve_artifact_path(artifact["file_path"])
+ if not artifact_path.exists():
+ continue
+ try:
+ fragment = artifact_path.read_text(encoding="utf-8")
+ body_match = _re.search(
+ r"]*>(.*)", fragment, _re.DOTALL | _re.IGNORECASE
+ )
+ if body_match:
+ fragment = body_match.group(1)
+ pages.append((artifact.get("day_label", ""), fragment))
+ except OSError:
+ pass
if not pages:
raise HTTPException(
@@ -654,6 +654,9 @@ def submit_packet_feedback(student_id: str, packet_id: str, request: SubmitFeedb
if not profile:
raise HTTPException(status_code=404, detail="Student not found")
+ # Check for existing feedback to handle update case
+ existing_feedback = get_packet_feedback(student_id, packet_id)
+
# Process feedback and update blobs
feedback_date = datetime.now(UTC).isoformat().replace("+00:00", "Z")
@@ -662,6 +665,12 @@ def submit_packet_feedback(student_id: str, packet_id: str, request: SubmitFeedb
)
plan_rules_blob = profile["plan_rules_blob"] or json.dumps({})
+ # When updating, reverse the old quantity effect before applying the new one
+ if existing_feedback and existing_feedback.get("quantity_feedback") is not None:
+ plan_rules_blob = reverse_quantity_feedback(
+ plan_rules_blob, existing_feedback["quantity_feedback"]
+ )
+
if mastery_feedback:
progress_blob = process_mastery_feedback(progress_blob, mastery_feedback, feedback_date)
diff --git a/src/packet_store.py b/src/packet_store.py
index 14c66df..2cb13fd 100644
--- a/src/packet_store.py
+++ b/src/packet_store.py
@@ -338,15 +338,17 @@ def list_weekly_packets(
ensure_schema()
limit = max(1, limit)
query = [
- "SELECT packet_id, student_id, grade_level, subject, week_of, status, summary_json, updated_at",
- "FROM weekly_packets",
- "WHERE student_id = ?",
+ "SELECT wp.packet_id, wp.student_id, wp.grade_level, wp.subject, wp.week_of, wp.status, wp.summary_json, wp.updated_at,",
+ " pf.completed_at AS feedback_completed_at",
+ "FROM weekly_packets wp",
+ "LEFT JOIN packet_feedback pf ON pf.packet_id = wp.packet_id AND pf.student_id = wp.student_id",
+ "WHERE wp.student_id = ?",
]
params: list[Any] = [student_id]
if week_of:
- query.append("AND week_of = ?")
+ query.append("AND wp.week_of = ?")
params.append(week_of)
- query.append("ORDER BY week_of DESC, updated_at DESC")
+ query.append("ORDER BY wp.week_of DESC, wp.updated_at DESC")
query.append("LIMIT ? OFFSET ?")
params.extend([limit + 1, offset])
@@ -363,6 +365,7 @@ def list_weekly_packets(
summaries: list[dict[str, Any]] = []
for row in rows_to_use:
summary = _deserialize_summary(row["summary_json"])
+ feedback_completed_at = row["feedback_completed_at"]
summaries.append(
{
"packet_id": row["packet_id"],
@@ -376,6 +379,8 @@ def list_weekly_packets(
"artifact_count": summary.get("artifact_count", 0),
"resource_days": summary.get("resource_days", 0),
"daily_count": summary.get("daily_count", 0),
+ "has_feedback": feedback_completed_at is not None,
+ "feedback_completed_at": feedback_completed_at,
}
)
@@ -522,29 +527,36 @@ def save_packet_feedback(
if not _packet_exists(conn, student_id, packet_id):
raise ValueError(f"Packet {packet_id} not found for student {student_id}")
- # Check if feedback already exists
existing = conn.execute(
"SELECT feedback_id FROM packet_feedback WHERE packet_id = ? AND student_id = ?",
(packet_id, student_id),
).fetchone()
- if existing:
- raise ValueError(f"Feedback already exists for packet {packet_id}")
-
- # Insert feedback
mastery_blob = _json(mastery_feedback) if mastery_feedback else None
- conn.execute(
- """
- INSERT INTO packet_feedback (
- packet_id,
- student_id,
- completed_at,
- mastery_feedback_blob,
- quantity_feedback
- ) VALUES (?, ?, ?, ?, ?)
- """,
- (packet_id, student_id, _utc_now(), mastery_blob, quantity_feedback),
- )
+ if existing:
+ conn.execute(
+ """
+ UPDATE packet_feedback SET
+ completed_at = ?,
+ mastery_feedback_blob = ?,
+ quantity_feedback = ?
+ WHERE packet_id = ? AND student_id = ?
+ """,
+ (_utc_now(), mastery_blob, quantity_feedback, packet_id, student_id),
+ )
+ else:
+ conn.execute(
+ """
+ INSERT INTO packet_feedback (
+ packet_id,
+ student_id,
+ completed_at,
+ mastery_feedback_blob,
+ quantity_feedback
+ ) VALUES (?, ?, ?, ?, ?)
+ """,
+ (packet_id, student_id, _utc_now(), mastery_blob, quantity_feedback),
+ )
conn.commit()
finally:
conn.close()
diff --git a/tests/test_generate_phonics_blends.py b/tests/test_generate_phonics_blends.py
new file mode 100644
index 0000000..93dde8f
--- /dev/null
+++ b/tests/test_generate_phonics_blends.py
@@ -0,0 +1,58 @@
+# tests/test_generate_phonics_blends.py
+import os
+import subprocess
+import pytest
+
+OUTPUT_DIR = "output/phonics_blends"
+EXPECTED_FILES = [
+ "01_l_blends.html",
+ "02_r_blends.html",
+ "03_s_blends.html",
+ "04_final_blends.html",
+]
+
+
+@pytest.fixture(scope="module", autouse=True)
+def run_script():
+ result = subprocess.run(
+ ["python", "scripts/generate_phonics_blends_series.py"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, f"Script failed:\n{result.stderr}"
+
+
+def test_all_files_created():
+ for fname in EXPECTED_FILES:
+ path = os.path.join(OUTPUT_DIR, fname)
+ assert os.path.exists(path), f"Missing output file: {path}"
+
+
+def test_word_count_per_file():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ count = content.count('class="word"')
+ assert count >= 25, f"{fname}: expected >=25 words, got {count}"
+
+
+def test_grapheme_chunks_present():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ assert 'class="blend-part"' in content, f"{fname}: missing grapheme chunks"
+ chunk_count = content.count('class="blend-part"')
+ word_count = content.count('class="word"')
+ assert (
+ chunk_count / word_count >= 0.10
+ ), f"{fname}: chunk ratio {chunk_count}/{word_count} is below 10%"
+
+
+def test_opendyslexic_referenced():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ assert "OpenDyslexic" in content, f"{fname}: OpenDyslexic font not referenced"
+
+
+def test_auto_print_present():
+ for fname in EXPECTED_FILES:
+ content = open(os.path.join(OUTPUT_DIR, fname)).read()
+ assert "window.print()" in content, f"{fname}: missing auto-print trigger"