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 (
+
+
setOpen(o => !o)}
+ className="w-full flex items-center justify-between px-4 py-3 text-left"
+ >
+
+
+
+
+ Bedienungsanleitung
+
+ (6 Schritte vom Setup bis zum fertigen Stundenplan)
+
+
+ ▾
+
+ {open && (
+
+ {STEPS.map((s, i) => (
+
+ ))}
+
+ 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 || '?'}
+
togglePin(lesson)}
+ data-testid={`pin-${lesson.id}`}
+ title={lesson.pinned ? 'Lesson loesen' : 'Lesson anpinnen'}
+ className={`absolute top-1 right-1 text-xs leading-none px-1 py-0.5 rounded ${
+ lesson.pinned
+ ? 'text-amber-300 hover:text-amber-200'
+ : 'opacity-30 hover:opacity-100'
+ }`}
+ >
+ {lesson.pinned ? '🔒' : '📌'}
+
+
{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 (
-
-
+
+
Name (optional)
setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
+
+ Basieren auf (optional)
+ setParentId(e.target.value)}
+ disabled={completedParents.length === 0}
+ data-testid="parent-selector"
+ className={`w-full px-3 py-2 rounded-lg border disabled:opacity-50 ${inputClass}`}
+ >
+ — ohne Vorlage —
+ {completedParents.map(p => (
+ {p.name || p.id.slice(0, 8)}
+ ))}
+
+
+
+ Sekunden-Limit
+ 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. */}
+
+
+
-
-
+
-
-
-
- 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'
- }`}
- />
-
- Speichern
-
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+ An BreakPilot anmelden (z.B. ueber das Lehrer-Login)
+ Im Browser DevTools → Application/Storage → Cookies oder localStorage den
+ JWT-Token kopieren (Feldname kann je nach Login-Flow variieren)
+ Token unten einfuegen, Speichern, Seite neu laden
+
+
+ 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'
+ }`}
+ />
+
+ Speichern
+
+
+ {tokenSaved && (
+
+ Token gespeichert. Seite neu laden, um die Aenderung zu uebernehmen.
+
+ )}
+
+
+
+
+
+ {TABS.map(t => (
+ setTab(t.id)}
+ className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
+ tab === t.id
+ ? isDark
+ ? 'bg-white/20 text-white shadow-lg'
+ : 'bg-indigo-600 text-white shadow-lg'
+ : isDark
+ ? 'bg-white/5 text-white/70 hover:bg-white/15'
+ : 'bg-white/70 text-slate-700 hover:bg-white border border-slate-200'
+ }`}
+ >
+ {t.label}
+
+ ))}
+
+
+
+ {tab === 'plan' && }
+ {tab === 'klassen' && }
+ {tab === 'lehrer' && }
+ {tab === 'faecher' && }
+ {tab === 'raeume' && }
+ {tab === 'periods' && }
+ {tab === 'curriculum' && }
+ {tab === 'assignments' && }
+ {tab === 'regeln' && }
+
-
-
- {TABS.map(t => (
- setTab(t.id)}
- className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
- tab === t.id
- ? isDark
- ? 'bg-white/20 text-white'
- : 'bg-indigo-100 text-indigo-900'
- : isDark
- ? 'bg-white/5 text-white/70 hover:bg-white/10'
- : 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
- }`}
- >
- {t.label}
-
- ))}
-
-
-
- {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