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
+3
View File
@@ -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
@@ -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`,
}
}
@@ -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})
}
@@ -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"`
}
@@ -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
@@ -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)
}
})
}
}