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
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user