Phase 7: pinning, plan versions, solver budget + UX polish
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 23s

Backend (school-service):
  - tt_solution gains parent_solution_id (self-FK, ON DELETE SET NULL)
    and seconds_limit columns via ALTER TABLE IF NOT EXISTS.
  - CreateTimetableSolutionRequest accepts optional parent_solution_id
    and seconds_limit (5-600s) with binding validation.
  - CreateSolution checks parent ownership before INSERT so users can't
    fork another tenant's plan.
  - New PUT /timetable/lessons/:id/pin endpoint; ownership enforced via
    the lesson's solution.created_by_user_id JOIN.

Solver:
  - Lesson.pinned now carries @PlanningPin so Timefold leaves locked
    cells untouched during the search.
  - build_problem() takes optional parent_solution_id; if set, copies
    pinned (class_id, subject_id, day, period, room) tuples onto fresh
    Lesson objects via greedy first-fit matching. Surplus pinned rows
    from curriculum changes are silently dropped.
  - _build_factory(seconds) replaces the module-level factory so each
    job honours its tt_solution.seconds_limit override.
  - persist_solution writes lesson.pinned back so subsequent re-solves
    inherit it.

Frontend (studio-v2):
  - SolutionList grows three knobs in the create-form: Basieren auf
    (parent dropdown, only completed solutions, disabled when none),
    Sekunden-Limit (5-600), and the existing Name.
  - PlanView cells get a pin/unpin button with optimistic update and
    rollback on error. Pinned cells gain an amber ring.
  - types.ts + api.ts mirror the new fields; lessonsApi.pin(id, bool).
  - HelpPanel: collapsible 6-step Bedienungsanleitung explaining the
    setup-to-plan workflow. Anchored at the top of /stundenplan above
    the dev token banner.
  - page.tsx switches to the same gradient + animated-blob background
    used on /korrektur so /stundenplan stops looking like a slate-900
    test page.
  - JWT dev banner gets a step-by-step explanation of how to grab the
    token from DevTools and a non-blocking success indicator (no more
    alert()).

Tests:
  - school-service: 6 new validator cases for parent_solution_id +
    seconds_limit boundaries. 73 subtests total, all green.
  - studio-v2: mockSchoolApi adds PUT /lessons/:id/pin route. 5 new
    Playwright tests across two suites (parent-selector visibility +
    options, seconds-limit input, pin button render, pin-icon flip).
    Existing tests adjusted to the new help panel + JWT banner wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-22 08:19:39 +02:00
parent 612ecec6d9
commit bf5ea860cc
17 changed files with 591 additions and 124 deletions
+64 -2
View File
@@ -17,7 +17,11 @@ from .rules import (
)
async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable:
async def build_problem(
pool: asyncpg.Pool,
user_id: str,
parent_solution_id: str | None = None,
) -> Timetable:
async with pool.acquire() as conn:
timeslots = await _load_timeslots(conn, user_id)
rooms = await _load_rooms(conn, user_id)
@@ -27,6 +31,11 @@ async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable:
lessons = await _build_lessons(conn, user_id, classes, subjects, teachers)
rules = await _load_rules(conn, user_id)
if parent_solution_id:
await _inherit_pinned_from_parent(
conn, parent_solution_id, lessons, timeslots, rooms,
)
return Timetable(
timeslots=timeslots,
rooms=rooms,
@@ -39,6 +48,58 @@ async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable:
)
async def _inherit_pinned_from_parent(
conn: asyncpg.Connection,
parent_solution_id: str,
lessons: list[Lesson],
timeslots: list[Timeslot],
rooms: list[Room],
) -> None:
"""Apply pinned lessons from a parent solution onto the new problem.
Matching rule: parent pinned lesson maps to a new Lesson with the same
(class_id, subject_id) that hasn't already received a pinned assignment.
Greedy first-fit so the count matches the parent's pinned count up to
the curriculum's weekly_hours per (class, subject). If curriculum
changed between solves, surplus pinned rows are silently dropped.
"""
rows = await conn.fetch("""
SELECT class_id::text, subject_id::text, room_id::text,
day_of_week, period_index
FROM tt_lesson
WHERE solution_id = $1 AND pinned = true
""", parent_solution_id)
if not rows:
return
ts_by_dp = {(t.day_of_week, t.period_index): t for t in timeslots}
room_by_id = {r.id: r for r in rooms}
used: set[str] = set()
for r in rows:
ts = ts_by_dp.get((r["day_of_week"], r["period_index"]))
if ts is None:
continue # period was deleted in the meantime
# Find a not-yet-pinned lesson with matching class+subject.
candidate: Lesson | None = None
for lesson in lessons:
if lesson.id in used:
continue
if (lesson.school_class.id == r["class_id"] and
lesson.subject.id == r["subject_id"]):
candidate = lesson
break
if candidate is None:
continue
candidate.timeslot = ts
room_id = r["room_id"]
if room_id:
candidate.room = room_by_id.get(room_id)
candidate.pinned = True
used.add(candidate.id)
async def _load_timeslots(conn: asyncpg.Connection, user_id: str) -> list[Timeslot]:
rows = await conn.fetch("""
SELECT id::text, day_of_week, period_index,
@@ -217,7 +278,7 @@ async def persist_solution(
INSERT INTO tt_lesson
(solution_id, class_id, subject_id, teacher_id, room_id,
day_of_week, period_index, pinned)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, false)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8)
""",
solution_id,
lesson.school_class.id,
@@ -226,6 +287,7 @@ async def persist_solution(
lesson.room.id if lesson.room else None,
lesson.timeslot.day_of_week,
lesson.timeslot.period_index,
lesson.pinned,
)