From bf5ea860cc12b26efe461d94e42d90ed68b62c57 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 22 May 2026 08:19:39 +0200 Subject: [PATCH] Phase 7: pinning, plan versions, solver budget + UX polish 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) --- school-service/cmd/server/main.go | 3 + .../database/timetable_solution_migrations.go | 4 + .../handlers/timetable_solution_handlers.go | 20 ++ .../internal/models/timetable_solutions.go | 36 ++-- .../internal/services/timetable_solutions.go | 56 +++++- .../services/timetable_solutions_test.go | 26 +++ .../app/stundenplan/_components/HelpPanel.tsx | 76 +++++++ .../stundenplan/_components/plan/PlanView.tsx | 31 ++- .../_components/plan/SolutionList.tsx | 52 ++++- studio-v2/app/stundenplan/page.tsx | 186 +++++++++++------- studio-v2/app/stundenplan/types.ts | 4 + studio-v2/e2e/_helpers.ts | 10 + studio-v2/e2e/stundenplan.spec.ts | 85 +++++++- studio-v2/lib/stundenplan/api.ts | 8 + timetable-solver-service/app/domain.py | 6 + timetable-solver-service/app/repository.py | 66 ++++++- timetable-solver-service/app/runner.py | 46 +++-- 17 files changed, 591 insertions(+), 124 deletions(-) create mode 100644 studio-v2/app/stundenplan/_components/HelpPanel.tsx diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index a7c29b1..75b1ac2 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -225,6 +225,9 @@ func main() { api.GET("/timetable/solutions/:id", handler.GetTimetableSolution) api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution) api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons) + + // Phase 7: pin/unpin individual lessons for the next re-solve. + api.PUT("/timetable/lessons/:id/pin", handler.UpdateTimetableLessonPin) } // Start server diff --git a/school-service/internal/database/timetable_solution_migrations.go b/school-service/internal/database/timetable_solution_migrations.go index 4fca032..8b1b3a0 100644 --- a/school-service/internal/database/timetable_solution_migrations.go +++ b/school-service/internal/database/timetable_solution_migrations.go @@ -46,5 +46,9 @@ func TimetableSolutionMigrations() []string { `CREATE INDEX IF NOT EXISTS idx_tt_lesson_solution ON tt_lesson(solution_id)`, `CREATE INDEX IF NOT EXISTS idx_tt_lesson_class ON tt_lesson(class_id)`, `CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_id)`, + + // Phase 7: plan versioning + per-solve solver-timeout override. + `ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS parent_solution_id UUID REFERENCES tt_solution(id) ON DELETE SET NULL`, + `ALTER TABLE tt_solution ADD COLUMN IF NOT EXISTS seconds_limit INT`, } } diff --git a/school-service/internal/handlers/timetable_solution_handlers.go b/school-service/internal/handlers/timetable_solution_handlers.go index 2eac1f1..f321f73 100644 --- a/school-service/internal/handlers/timetable_solution_handlers.go +++ b/school-service/internal/handlers/timetable_solution_handlers.go @@ -89,3 +89,23 @@ func (h *Handler) DeleteTimetableSolution(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Solution deleted"}) } + +// UpdateTimetableLessonPin flips the pinned flag on a single lesson. +// The solver respects pinned cells via @PlanningPin when this user re-solves. +func (h *Handler) UpdateTimetableLessonPin(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.UpdateLessonPinRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if err := h.timetableService.UpdateLessonPin(c.Request.Context(), c.Param("id"), uid, req.Pinned); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to update lesson pin: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Lesson pin updated", "pinned": req.Pinned}) +} diff --git a/school-service/internal/models/timetable_solutions.go b/school-service/internal/models/timetable_solutions.go index 278febb..0dafed1 100644 --- a/school-service/internal/models/timetable_solutions.go +++ b/school-service/internal/models/timetable_solutions.go @@ -9,16 +9,18 @@ import ( // TimetableSolution is one run of the solver — exactly one row per solve. // Lessons attached via tt_lesson.solution_id. type TimetableSolution struct { - ID uuid.UUID `json:"id" db:"id"` - CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` - Name string `json:"name,omitempty" db:"name"` - Status string `json:"status" db:"status"` - HardScore *int `json:"hard_score,omitempty" db:"hard_score"` - SoftScore *int `json:"soft_score,omitempty" db:"soft_score"` - ErrorMessage string `json:"error_message,omitempty" db:"error_message"` - StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + Name string `json:"name,omitempty" db:"name"` + Status string `json:"status" db:"status"` + HardScore *int `json:"hard_score,omitempty" db:"hard_score"` + SoftScore *int `json:"soft_score,omitempty" db:"soft_score"` + ErrorMessage string `json:"error_message,omitempty" db:"error_message"` + StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + ParentSolutionID *uuid.UUID `json:"parent_solution_id,omitempty" db:"parent_solution_id"` + SecondsLimit *int `json:"seconds_limit,omitempty" db:"seconds_limit"` } // TimetableLesson is one scheduled class-period in a solution. @@ -44,6 +46,18 @@ type TimetableLesson struct { // CreateTimetableSolutionRequest kicks off a solve. The solver-service is // invoked async — this endpoint only registers the solution row and queues // the job. +// +// ParentSolutionID, if set, instructs the solver to seed the new problem +// with the parent solution's pinned lessons (Phase 7 plan versioning). +// SecondsLimit overrides the default 60s solver budget. type CreateTimetableSolutionRequest struct { - Name string `json:"name"` + Name string `json:"name"` + ParentSolutionID *string `json:"parent_solution_id,omitempty" binding:"omitempty,uuid"` + SecondsLimit *int `json:"seconds_limit,omitempty" binding:"omitempty,min=5,max=600"` +} + +// UpdateLessonPinRequest toggles tt_lesson.pinned. Used by the Plan view's +// pin/unpin button. +type UpdateLessonPinRequest struct { + Pinned bool `json:"pinned"` } diff --git a/school-service/internal/services/timetable_solutions.go b/school-service/internal/services/timetable_solutions.go index 75d8dc5..eb43552 100644 --- a/school-service/internal/services/timetable_solutions.go +++ b/school-service/internal/services/timetable_solutions.go @@ -16,24 +16,63 @@ import ( // same DB once it finishes, so listing solutions = simple SELECTs here. func (s *TimetableService) CreateSolution(ctx context.Context, userID string, req *models.CreateTimetableSolutionRequest) (*models.TimetableSolution, error) { + // Resolve optional parent — guard against cross-user references. + var parentID *string + if req.ParentSolutionID != nil && *req.ParentSolutionID != "" { + var owned bool + err := s.db.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM tt_solution + WHERE id = $1 AND created_by_user_id = $2) + `, *req.ParentSolutionID, userID).Scan(&owned) + if err != nil { + return nil, err + } + if !owned { + return nil, fmt.Errorf("parent_solution_id not found or not owned by user") + } + parentID = req.ParentSolutionID + } + var sol models.TimetableSolution err := s.db.QueryRow(ctx, ` - INSERT INTO tt_solution (created_by_user_id, name, status) - VALUES ($1, $2, 'pending') + INSERT INTO tt_solution (created_by_user_id, name, status, parent_solution_id, seconds_limit) + VALUES ($1, $2, 'pending', $3::uuid, $4) RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, - COALESCE(error_message, ''), started_at, finished_at, created_at - `, userID, req.Name).Scan( + COALESCE(error_message, ''), started_at, finished_at, created_at, + parent_solution_id, seconds_limit + `, userID, req.Name, parentID, req.SecondsLimit).Scan( &sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, + &sol.ParentSolutionID, &sol.SecondsLimit, ) return &sol, err } +// UpdateLessonPin flips tt_lesson.pinned. Ownership is enforced via the +// lesson's solution.created_by_user_id — users can only pin their own +// solutions' lessons. +func (s *TimetableService) UpdateLessonPin(ctx context.Context, lessonID, userID string, pinned bool) error { + res, err := s.db.Exec(ctx, ` + UPDATE tt_lesson l + SET pinned = $1 + FROM tt_solution s + WHERE l.solution_id = s.id AND l.id = $2 AND s.created_by_user_id = $3 + `, pinned, lessonID, userID) + if err != nil { + return err + } + if res.RowsAffected() == 0 { + return fmt.Errorf("lesson not found or not owned") + } + return nil +} + func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) { rows, err := s.db.Query(ctx, ` SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, - COALESCE(error_message, ''), started_at, finished_at, created_at + COALESCE(error_message, ''), started_at, finished_at, created_at, + parent_solution_id, seconds_limit FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC `, userID) if err != nil { @@ -45,7 +84,8 @@ func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([] var sol models.TimetableSolution if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, - &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt); err != nil { + &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, + &sol.ParentSolutionID, &sol.SecondsLimit); err != nil { return nil, err } out = append(out, sol) @@ -57,12 +97,14 @@ func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) ( var sol models.TimetableSolution err := s.db.QueryRow(ctx, ` SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, - COALESCE(error_message, ''), started_at, finished_at, created_at + COALESCE(error_message, ''), started_at, finished_at, created_at, + parent_solution_id, seconds_limit FROM tt_solution WHERE id = $1 AND created_by_user_id = $2 `, id, userID).Scan( &sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, + &sol.ParentSolutionID, &sol.SecondsLimit, ) if err != nil { return nil, err diff --git a/school-service/internal/services/timetable_solutions_test.go b/school-service/internal/services/timetable_solutions_test.go index edbf5ab..838cfa5 100644 --- a/school-service/internal/services/timetable_solutions_test.go +++ b/school-service/internal/services/timetable_solutions_test.go @@ -25,3 +25,29 @@ func TestCreateTimetableSolutionRequest_NoBindingTags(t *testing.T) { }) } } + +func TestCreateTimetableSolutionRequest_ParentAndLimit_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000001" + secs := func(n int) *int { return &n } + parent := func(s string) *string { return &s } + + tests := []struct { + name string + req models.CreateTimetableSolutionRequest + wantErr bool + }{ + {"valid parent + limit", models.CreateTimetableSolutionRequest{ParentSolutionID: parent(uid), SecondsLimit: secs(60)}, false}, + {"bad parent uuid", models.CreateTimetableSolutionRequest{ParentSolutionID: parent("not-a-uuid")}, true}, + {"limit too low", models.CreateTimetableSolutionRequest{SecondsLimit: secs(1)}, true}, + {"limit too high", models.CreateTimetableSolutionRequest{SecondsLimit: secs(9999)}, true}, + {"limit at boundary (5)", models.CreateTimetableSolutionRequest{SecondsLimit: secs(5)}, false}, + {"limit at boundary (600)", models.CreateTimetableSolutionRequest{SecondsLimit: secs(600)}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome for %s", tt.name) + } + }) + } +} diff --git a/studio-v2/app/stundenplan/_components/HelpPanel.tsx b/studio-v2/app/stundenplan/_components/HelpPanel.tsx new file mode 100644 index 0000000..dc98d5c --- /dev/null +++ b/studio-v2/app/stundenplan/_components/HelpPanel.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useState } from 'react' +import { useTheme } from '@/lib/ThemeContext' + +const STEPS: { title: string; body: string }[] = [ + { + title: '1. Klassen, Lehrer, Faecher, Raeume anlegen', + body: 'Stammdaten zuerst — der Solver kann nur scheduln was er kennt. Ohne mindestens 1 Klasse, 1 Fach, 1 Raum und 1 Lehrer wird der Plan leer.', + }, + { + title: '2. Zeitraster definieren', + body: 'Wochentag + Stundennummer + Start/Ende fuer jeden Slot. Pausen anhaken; der Solver belegt sie nicht.', + }, + { + title: '3. Stundentafel + Lehrauftraege', + body: 'Stundentafel: pro Klasse, wie viele Wochenstunden welches Fach. Lehrauftraege: welcher Lehrer unterrichtet welches Fach in welcher Klasse. Ohne Lehrauftrag wird die Lesson uebersprungen.', + }, + { + title: '4. Regeln (Constraints) — optional', + body: 'Lehrer-Abwesenheiten, Fach-Bevorzugungen, Raum-Sperren. Hart-Regeln muss der Solver einhalten, Soft-Regeln werden gewichtet.', + }, + { + title: '5. Plan generieren', + body: 'Zurueck auf den Plan-Tab → "Neuen Plan generieren". Der Solver laeuft im Hintergrund (bis zu 60 s) und schreibt das Ergebnis direkt in die Datenbank. Status erscheint live in der Liste.', + }, + { + title: '6. Cells anpinnen + Re-Solve', + body: 'Im Wochengrid einzelne Stunden anpinnen (Schloss-Icon). Beim naechsten Solve mit dem Plan als "Basieren auf"-Quelle bleiben die gepinnten Cells stehen, alles andere wird neu gerechnet.', + }, +] + +export function HelpPanel() { + const { isDark } = useTheme() + const [open, setOpen] = useState(false) + + return ( +
+ + {open && ( +
+ {STEPS.map((s, i) => ( +
+

{s.title}

+

{s.body}

+
+ ))} +

+ Tipp: Solver-Probleme im Status-Feld der Plan-Liste — "Keine Lessons" heisst meistens fehlende + Lehrauftraege; "Nicht loesbar" heisst harte Constraints widersprechen sich. +

+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/plan/PlanView.tsx b/studio-v2/app/stundenplan/_components/plan/PlanView.tsx index 5319297..9efdb72 100644 --- a/studio-v2/app/stundenplan/_components/plan/PlanView.tsx +++ b/studio-v2/app/stundenplan/_components/plan/PlanView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useTheme } from '@/lib/ThemeContext' -import { solutionsApi, subjectsApi } from '@/lib/stundenplan/api' +import { solutionsApi, subjectsApi, lessonsApi } from '@/lib/stundenplan/api' import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types' interface PlanViewProps { @@ -109,6 +109,19 @@ export function PlanView({ solutionId }: PlanViewProps) { const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined => visibleLessons.find(l => l.day_of_week === day && l.period_index === periodIdx) + const togglePin = useCallback(async (lesson: TimetableLesson) => { + // Optimistic update so the lock icon flips immediately even if the + // server is slow. + setLessons(prev => prev.map(l => l.id === lesson.id ? { ...l, pinned: !l.pinned } : l)) + try { + await lessonsApi.pin(lesson.id, !lesson.pinned) + } catch (e) { + // Revert on failure and surface the error. + setLessons(prev => prev.map(l => l.id === lesson.id ? { ...l, pinned: lesson.pinned } : l)) + setError(e instanceof Error ? e.message : 'Pin fehlgeschlagen') + } + }, []) + const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900' @@ -177,11 +190,23 @@ export function PlanView({ solutionId }: PlanViewProps) { return (
-
{lesson.subject_name || '?'}
+ +
{lesson.subject_name || '?'}
{perspective !== 'class' && lesson.class_name && (
{lesson.class_name}
)} diff --git a/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx b/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx index 75e8b70..7524ea4 100644 --- a/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx +++ b/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx @@ -33,6 +33,8 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) { const [error, setError] = useState(null) const [submitting, setSubmitting] = useState(false) const [name, setName] = useState('') + const [parentId, setParentId] = useState('') + const [secondsLimit, setSecondsLimit] = useState('') const pollingRef = useRef | null>(null) const load = useCallback(async () => { @@ -70,8 +72,14 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) { setSubmitting(true) setError(null) try { - await solutionsApi.create({ name: name || `Plan ${new Date().toLocaleString('de-DE')}` }) + await solutionsApi.create({ + name: name || `Plan ${new Date().toLocaleString('de-DE')}`, + parent_solution_id: parentId || null, + seconds_limit: secondsLimit === '' ? null : Number(secondsLimit), + }) setName('') + setParentId('') + setSecondsLimit('') await load() } catch (err) { setError(err instanceof Error ? err.message : 'Solve fehlgeschlagen') @@ -93,14 +101,49 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) { const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' const inputClass = isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' + const completedParents = items.filter(s => s.status === 'completed') + return (
-
-
+
+
setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
+
+ + +
+
+ + setSecondsLimit(e.target.value === '' ? '' : parseInt(e.target.value))} + placeholder="60" + data-testid="seconds-limit" + className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} + /> +
+
+
+

+ Mit Vorlage uebernimmt der Solver alle gepinnten Cells aus dem Quellplan. +

-

- Solver laeuft im Hintergrund (bis zu 60 Sekunden). Status erscheint in der Liste. -

{error &&
{error}
} diff --git a/studio-v2/app/stundenplan/page.tsx b/studio-v2/app/stundenplan/page.tsx index 24fcd45..25c9582 100644 --- a/studio-v2/app/stundenplan/page.tsx +++ b/studio-v2/app/stundenplan/page.tsx @@ -14,6 +14,7 @@ import { CurriculumManager } from './_components/CurriculumManager' import { AssignmentsManager } from './_components/AssignmentsManager' import { RegelnHub } from './_components/regeln/RegelnHub' import { PlanHub } from './_components/plan/PlanHub' +import { HelpPanel } from './_components/HelpPanel' import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api' type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' @@ -34,92 +35,125 @@ export default function StundenplanPage() { const { isDark } = useTheme() const [tab, setTab] = useState('plan') const [token, setToken] = useState(getStundenplanToken()) + const [tokenSaved, setTokenSaved] = useState(false) const handleSaveToken = () => { setStundenplanToken(token) - alert('Token gespeichert. Seite neu laden um die Aenderung zu uebernehmen.') + setTokenSaved(true) + setTimeout(() => setTokenSaved(false), 2500) } return ( -
- +
+ {/* Background blobs — same effect as /korrektur to keep the visual + language consistent across studio-v2 pages. */} +
+
+
-
-
-
-

- Stundenplan -

-

- Stammdaten und Regeln fuer den Solver -

-
-
- - -
-
+
-
-
- - Dev: JWT-Token setzen (Anmeldung noch nicht integriert) - -
- setToken(e.target.value)} - placeholder="Bearer-Token" - className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${ - isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900' - }`} - /> - +
+
+
+
+

+ Stundenplan +

+

+ Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan +

-
+
+ + +
+ + + + +
+
+ + Anmeldung noch nicht integriert — Dev-Token setzen + +
+

+ Bis die volle BreakPilot-Anmeldung an dieses Modul angebunden ist, muss + ein gueltiger JWT-Token manuell hinterlegt werden. Ohne Token antwortet + die API mit Authorization header required. +

+
    +
  1. An BreakPilot anmelden (z.B. ueber das Lehrer-Login)
  2. +
  3. Im Browser DevTools → Application/Storage → Cookies oder localStorage den + JWT-Token kopieren (Feldname kann je nach Login-Flow variieren)
  4. +
  5. Token unten einfuegen, Speichern, Seite neu laden
  6. +
+
+ setToken(e.target.value)} + placeholder="Bearer-Token (ohne 'Bearer '-Prefix)" + className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${ + isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900' + }`} + /> + +
+ {tokenSaved && ( +

+ Token gespeichert. Seite neu laden, um die Aenderung zu uebernehmen. +

+ )} +
+
+
+ + + +
+ {tab === 'plan' && } + {tab === 'klassen' && } + {tab === 'lehrer' && } + {tab === 'faecher' && } + {tab === 'raeume' && } + {tab === 'periods' && } + {tab === 'curriculum' && } + {tab === 'assignments' && } + {tab === 'regeln' && } +
- - - -
- {tab === 'plan' && } - {tab === 'klassen' && } - {tab === 'lehrer' && } - {tab === 'faecher' && } - {tab === 'raeume' && } - {tab === 'periods' && } - {tab === 'curriculum' && } - {tab === 'assignments' && } - {tab === 'regeln' && } -
) diff --git a/studio-v2/app/stundenplan/types.ts b/studio-v2/app/stundenplan/types.ts index 0f59de8..558579b 100644 --- a/studio-v2/app/stundenplan/types.ts +++ b/studio-v2/app/stundenplan/types.ts @@ -240,6 +240,8 @@ export interface TimetableSolution { started_at?: string | null finished_at?: string | null created_at: string + parent_solution_id?: string | null + seconds_limit?: number | null } export interface TimetableLesson { @@ -261,4 +263,6 @@ export interface TimetableLesson { export interface CreateTimetableSolution { name?: string + parent_solution_id?: string | null + seconds_limit?: number | null } diff --git a/studio-v2/e2e/_helpers.ts b/studio-v2/e2e/_helpers.ts index 5cc59ca..888530d 100644 --- a/studio-v2/e2e/_helpers.ts +++ b/studio-v2/e2e/_helpers.ts @@ -118,6 +118,16 @@ export async function mockSchoolApi(page: Page, opts: MockOpts = {}) { await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) }) }) + + // Phase 7: lesson-level pin toggle. + await page.route(/\/api\/school\/timetable\/lessons\/[^/]+\/pin$/, async (route) => { + if (route.request().method() !== 'PUT') return route.fulfill({ status: 405 }) + const body = JSON.parse(route.request().postData() || '{}') + return route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ message: 'ok', pinned: body.pinned ?? false }), + }) + }) await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => { if (route.request().method() === 'DELETE') { return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' }) diff --git a/studio-v2/e2e/stundenplan.spec.ts b/studio-v2/e2e/stundenplan.spec.ts index a1cd2af..4279a59 100644 --- a/studio-v2/e2e/stundenplan.spec.ts +++ b/studio-v2/e2e/stundenplan.spec.ts @@ -18,7 +18,15 @@ test.describe('Stundenplan — Page Shell', () => { test('page loads with title and subtitle', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible() - await expect(page.getByText('Stammdaten und Regeln fuer den Solver')).toBeVisible() + await expect(page.getByText('Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan')).toBeVisible() + }) + + test('help panel toggles open', async ({ page }) => { + const panel = page.getByTestId('help-panel') + await expect(panel).toBeVisible() + await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toHaveCount(0) + await panel.getByRole('button', { name: /Bedienungsanleitung/ }).click() + await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toBeVisible() }) test('shows all 9 tabs', async ({ page }) => { @@ -34,9 +42,8 @@ test.describe('Stundenplan — Page Shell', () => { }) test('JWT dev field exists and persists into localStorage', async ({ page }) => { - await page.getByText('Dev: JWT-Token setzen').click() - page.on('dialog', d => d.accept()) - await page.getByPlaceholder('Bearer-Token').fill('test-jwt-abc') + await page.getByText('Anmeldung noch nicht integriert').click() + await page.getByPlaceholder(/Bearer-Token/).fill('test-jwt-abc') await page.getByRole('button', { name: 'Speichern' }).click() const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt')) expect(stored).toBe('test-jwt-abc') @@ -401,3 +408,73 @@ test.describe('Stundenplan — PlanView grid', () => { await expect(page.locator('select').last()).toHaveValue('r1') }) }) + +// ========================================================================== +// Phase 7 — Pinning + Plan-Versionen +// ========================================================================== + +test.describe('Stundenplan — Solve options (parent + seconds limit)', () => { + test('parent-selector disabled when no completed solutions exist', async ({ page }) => { + await mockSchoolApi(page, { solutions: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('parent-selector')).toBeDisabled() + }) + + test('parent-selector lists only completed solutions', async ({ page }) => { + await mockSchoolApi(page, { + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + { id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', created_at: '2026-05-22T11:00:00Z' }, + { id: 's3', created_by_user_id: 'u', name: 'Plan C', status: 'completed', hard_score: 0, soft_score: -5, created_at: '2026-05-22T12:00:00Z' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + const sel = page.getByTestId('parent-selector') + await expect(sel).toBeEnabled() + // Only Plan A and Plan C are options (Plan B failed). + await expect(sel.locator('option', { hasText: 'Plan A' })).toHaveCount(1) + await expect(sel.locator('option', { hasText: 'Plan C' })).toHaveCount(1) + await expect(sel.locator('option', { hasText: 'Plan B' })).toHaveCount(0) + }) + + test('seconds-limit field accepts numeric input', async ({ page }) => { + await mockSchoolApi(page, { solutions: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + const field = page.getByTestId('seconds-limit') + await field.fill('120') + await expect(field).toHaveValue('120') + }) +}) + +test.describe('Stundenplan — Pin lesson', () => { + const baseOpts = () => ({ + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + ], + lessons: [ + { id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' }, + ], + }) + + test('pin button is rendered on each lesson cell', async ({ page }) => { + await mockSchoolApi(page, baseOpts()) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Anzeigen' }).click() + await expect(page.getByTestId('pin-l1')).toBeVisible() + }) + + test('clicking pin flips the icon via optimistic update', async ({ page }) => { + await mockSchoolApi(page, baseOpts()) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Anzeigen' }).click() + const pinBtn = page.getByTestId('pin-l1') + await expect(pinBtn).toContainText('📌') + await pinBtn.click() + await expect(pinBtn).toContainText('🔒') + }) +}) diff --git a/studio-v2/lib/stundenplan/api.ts b/studio-v2/lib/stundenplan/api.ts index 7c9caed..e2f6f69 100644 --- a/studio-v2/lib/stundenplan/api.ts +++ b/studio-v2/lib/stundenplan/api.ts @@ -152,3 +152,11 @@ export const solutionsApi = { lessons: (id: string) => apiFetch(`/timetable/solutions/${id}/lessons`), } + +export const lessonsApi = { + pin: (id: string, pinned: boolean) => + apiFetch<{ message: string; pinned: boolean }>(`/timetable/lessons/${id}/pin`, { + method: 'PUT', + body: JSON.stringify({ pinned }), + }), +} diff --git a/timetable-solver-service/app/domain.py b/timetable-solver-service/app/domain.py index 605ec00..31ccd2b 100644 --- a/timetable-solver-service/app/domain.py +++ b/timetable-solver-service/app/domain.py @@ -15,6 +15,7 @@ from timefold.solver.domain import ( planning_entity, planning_solution, PlanningVariable, + PlanningPin, PlanningId, PlanningEntityCollectionProperty, ProblemFactCollectionProperty, @@ -88,6 +89,10 @@ class Lesson: Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment). The solver assigns timeslot + room. + + pinned (Phase 7): when True, this Lesson's timeslot + room have been + pre-assigned and the solver must not move it. Used for plan versioning + so re-solves keep the parent solution's locked cells in place. """ id: Annotated[str, PlanningId] @@ -96,6 +101,7 @@ class Lesson: teacher: Teacher timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None) room: Annotated[Optional[Room], PlanningVariable] = field(default=None) + pinned: Annotated[bool, PlanningPin] = field(default=False) def __str__(self) -> str: return f"{self.school_class}-{self.subject}#{self.id[:8]}" diff --git a/timetable-solver-service/app/repository.py b/timetable-solver-service/app/repository.py index d359236..ebb6416 100644 --- a/timetable-solver-service/app/repository.py +++ b/timetable-solver-service/app/repository.py @@ -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, ) diff --git a/timetable-solver-service/app/runner.py b/timetable-solver-service/app/runner.py index cd26f3f..d550a1b 100644 --- a/timetable-solver-service/app/runner.py +++ b/timetable-solver-service/app/runner.py @@ -35,32 +35,48 @@ logger = logging.getLogger(__name__) _executor = ThreadPoolExecutor(max_workers=2) -_solver_factory = SolverFactory.create( - SolverConfig( - solution_class=Timetable, - entity_class_list=[Lesson], - score_director_factory_config=ScoreDirectorFactoryConfig( - constraint_provider_function=define_constraints, - ), - termination_config=TerminationConfig( - spent_limit=Duration(seconds=settings.solver_seconds_limit), - ), +def _build_factory(seconds: int) -> SolverFactory: + """One factory per solve so we can honour per-job timeout overrides. + Cheap to construct — the Java side caches what it can across builds.""" + return SolverFactory.create( + SolverConfig( + solution_class=Timetable, + entity_class_list=[Lesson], + score_director_factory_config=ScoreDirectorFactoryConfig( + constraint_provider_function=define_constraints, + ), + termination_config=TerminationConfig( + spent_limit=Duration(seconds=seconds), + ), + ) ) -) -def _solve_sync(problem: Timetable) -> Timetable: +def _solve_sync(problem: Timetable, seconds: int) -> Timetable: """Blocking solver call; runs in a worker thread.""" - solver = _solver_factory.build_solver() + solver = _build_factory(seconds).build_solver() return solver.solve(problem) +async def _fetch_solve_options(pool, solution_id: str) -> tuple[str | None, int]: + """Read parent_solution_id + seconds_limit from tt_solution.""" + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT parent_solution_id::text, seconds_limit + FROM tt_solution WHERE id = $1 + """, solution_id) + parent = row["parent_solution_id"] if row else None + limit = row["seconds_limit"] if row and row["seconds_limit"] else settings.solver_seconds_limit + return parent, int(limit) + + async def run_solve(solution_id: str, user_id: str) -> None: """Top-level async entry. Caller fires-and-forgets via BackgroundTasks.""" pool = await get_pool() try: + parent_id, seconds = await _fetch_solve_options(pool, solution_id) await mark_running(pool, solution_id) - problem = await build_problem(pool, user_id) + problem = await build_problem(pool, user_id, parent_solution_id=parent_id) if not problem.lessons: await mark_failed(pool, solution_id, @@ -76,7 +92,7 @@ async def run_solve(solution_id: str, user_id: str) -> None: return loop = asyncio.get_running_loop() - solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem) + solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem, seconds) score = solved.score hard = score.hard_score() if score else 0