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.GET("/timetable/solutions/:id", handler.GetTimetableSolution)
|
||||||
api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution)
|
api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution)
|
||||||
api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons)
|
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
|
// 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_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_class ON tt_lesson(class_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_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"})
|
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.
|
// TimetableSolution is one run of the solver — exactly one row per solve.
|
||||||
// Lessons attached via tt_lesson.solution_id.
|
// Lessons attached via tt_lesson.solution_id.
|
||||||
type TimetableSolution struct {
|
type TimetableSolution struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
||||||
Name string `json:"name,omitempty" db:"name"`
|
Name string `json:"name,omitempty" db:"name"`
|
||||||
Status string `json:"status" db:"status"`
|
Status string `json:"status" db:"status"`
|
||||||
HardScore *int `json:"hard_score,omitempty" db:"hard_score"`
|
HardScore *int `json:"hard_score,omitempty" db:"hard_score"`
|
||||||
SoftScore *int `json:"soft_score,omitempty" db:"soft_score"`
|
SoftScore *int `json:"soft_score,omitempty" db:"soft_score"`
|
||||||
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
|
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
|
||||||
StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"`
|
StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"`
|
||||||
FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
|
FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_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.
|
// 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
|
// CreateTimetableSolutionRequest kicks off a solve. The solver-service is
|
||||||
// invoked async — this endpoint only registers the solution row and queues
|
// invoked async — this endpoint only registers the solution row and queues
|
||||||
// the job.
|
// 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 {
|
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.
|
// 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) {
|
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
|
var sol models.TimetableSolution
|
||||||
err := s.db.QueryRow(ctx, `
|
err := s.db.QueryRow(ctx, `
|
||||||
INSERT INTO tt_solution (created_by_user_id, name, status)
|
INSERT INTO tt_solution (created_by_user_id, name, status, parent_solution_id, seconds_limit)
|
||||||
VALUES ($1, $2, 'pending')
|
VALUES ($1, $2, 'pending', $3::uuid, $4)
|
||||||
RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
|
RETURNING 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,
|
||||||
`, userID, req.Name).Scan(
|
parent_solution_id, seconds_limit
|
||||||
|
`, userID, req.Name, parentID, req.SecondsLimit).Scan(
|
||||||
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
||||||
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
||||||
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
||||||
|
&sol.ParentSolutionID, &sol.SecondsLimit,
|
||||||
)
|
)
|
||||||
return &sol, err
|
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) {
|
func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) {
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
|
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
|
FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC
|
||||||
`, userID)
|
`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,7 +84,8 @@ func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]
|
|||||||
var sol models.TimetableSolution
|
var sol models.TimetableSolution
|
||||||
if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
||||||
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, sol)
|
out = append(out, sol)
|
||||||
@@ -57,12 +97,14 @@ func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) (
|
|||||||
var sol models.TimetableSolution
|
var sol models.TimetableSolution
|
||||||
err := s.db.QueryRow(ctx, `
|
err := s.db.QueryRow(ctx, `
|
||||||
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
|
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
|
FROM tt_solution WHERE id = $1 AND created_by_user_id = $2
|
||||||
`, id, userID).Scan(
|
`, id, userID).Scan(
|
||||||
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
|
||||||
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
|
||||||
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
|
||||||
|
&sol.ParentSolutionID, &sol.SecondsLimit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`mb-4 rounded-2xl border backdrop-blur-xl ${
|
||||||
|
isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
}`}
|
||||||
|
data-testid="help-panel"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 text-left"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Bedienungsanleitung
|
||||||
|
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||||
|
(6 Schritte vom Setup bis zum fertigen Stundenplan)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={`px-4 pb-4 space-y-3 border-t ${isDark ? 'border-white/10' : 'border-black/10'}`}>
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={i} className="pt-3">
|
||||||
|
<h4 className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{s.title}</h4>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{s.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className={`pt-3 text-xs italic ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
|
||||||
|
Tipp: Solver-Probleme im Status-Feld der Plan-Liste — "Keine Lessons" heisst meistens fehlende
|
||||||
|
Lehrauftraege; "Nicht loesbar" heisst harte Constraints widersprechen sich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
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'
|
import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types'
|
||||||
|
|
||||||
interface PlanViewProps {
|
interface PlanViewProps {
|
||||||
@@ -109,6 +109,19 @@ export function PlanView({ solutionId }: PlanViewProps) {
|
|||||||
const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined =>
|
const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined =>
|
||||||
visibleLessons.find(l => l.day_of_week === day && l.period_index === periodIdx)
|
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 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'
|
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 (
|
return (
|
||||||
<td key={d.v} className="px-2 py-1">
|
<td key={d.v} className="px-2 py-1">
|
||||||
<div
|
<div
|
||||||
className="rounded-md p-2 text-xs space-y-0.5"
|
className={`rounded-md p-2 text-xs space-y-0.5 relative ${lesson.pinned ? 'ring-2 ring-amber-400/70' : ''}`}
|
||||||
style={{ backgroundColor: color + (isDark ? '40' : '30'), borderLeft: `3px solid ${color}` }}
|
style={{ backgroundColor: color + (isDark ? '40' : '30'), borderLeft: `3px solid ${color}` }}
|
||||||
data-testid={`cell-${d.v}-${idx}`}
|
data-testid={`cell-${d.v}-${idx}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold">{lesson.subject_name || '?'}</div>
|
<button
|
||||||
|
onClick={() => 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 ? '🔒' : '📌'}
|
||||||
|
</button>
|
||||||
|
<div className="font-semibold pr-5">{lesson.subject_name || '?'}</div>
|
||||||
{perspective !== 'class' && lesson.class_name && (
|
{perspective !== 'class' && lesson.class_name && (
|
||||||
<div className="opacity-80">{lesson.class_name}</div>
|
<div className="opacity-80">{lesson.class_name}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
const [parentId, setParentId] = useState<string>('')
|
||||||
|
const [secondsLimit, setSecondsLimit] = useState<number | ''>('')
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -70,8 +72,14 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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('')
|
setName('')
|
||||||
|
setParentId('')
|
||||||
|
setSecondsLimit('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Solve fehlgeschlagen')
|
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 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 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 (
|
return (
|
||||||
<div className="space-y-4" data-testid="solution-list">
|
<div className="space-y-4" data-testid="solution-list">
|
||||||
<div className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
<div className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
<div className="flex items-end gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
<div className="flex-1">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm mb-1 opacity-70">Name (optional)</label>
|
<label className="block text-sm mb-1 opacity-70">Name (optional)</label>
|
||||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Basieren auf (optional)</label>
|
||||||
|
<select
|
||||||
|
value={parentId}
|
||||||
|
onChange={e => 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}`}
|
||||||
|
>
|
||||||
|
<option value="">— ohne Vorlage —</option>
|
||||||
|
{completedParents.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name || p.id.slice(0, 8)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Sekunden-Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={600}
|
||||||
|
value={secondsLimit}
|
||||||
|
onChange={e => 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}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
|
||||||
|
Mit Vorlage uebernimmt der Solver alle gepinnten Cells aus dem Quellplan.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSolve}
|
onClick={handleSolve}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
@@ -110,9 +153,6 @@ export function SolutionList({ onView, selectedId }: SolutionListProps) {
|
|||||||
{submitting ? 'Startet…' : 'Neuen Plan generieren'}
|
{submitting ? 'Startet…' : 'Neuen Plan generieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
|
|
||||||
Solver laeuft im Hintergrund (bis zu 60 Sekunden). Status erscheint in der Liste.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CurriculumManager } from './_components/CurriculumManager'
|
|||||||
import { AssignmentsManager } from './_components/AssignmentsManager'
|
import { AssignmentsManager } from './_components/AssignmentsManager'
|
||||||
import { RegelnHub } from './_components/regeln/RegelnHub'
|
import { RegelnHub } from './_components/regeln/RegelnHub'
|
||||||
import { PlanHub } from './_components/plan/PlanHub'
|
import { PlanHub } from './_components/plan/PlanHub'
|
||||||
|
import { HelpPanel } from './_components/HelpPanel'
|
||||||
import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api'
|
import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api'
|
||||||
|
|
||||||
type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
|
type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
|
||||||
@@ -34,92 +35,125 @@ export default function StundenplanPage() {
|
|||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const [tab, setTab] = useState<Tab>('plan')
|
const [tab, setTab] = useState<Tab>('plan')
|
||||||
const [token, setToken] = useState(getStundenplanToken())
|
const [token, setToken] = useState(getStundenplanToken())
|
||||||
|
const [tokenSaved, setTokenSaved] = useState(false)
|
||||||
|
|
||||||
const handleSaveToken = () => {
|
const handleSaveToken = () => {
|
||||||
setStundenplanToken(token)
|
setStundenplanToken(token)
|
||||||
alert('Token gespeichert. Seite neu laden um die Aenderung zu uebernehmen.')
|
setTokenSaved(true)
|
||||||
|
setTimeout(() => setTokenSaved(false), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen p-4 gap-4 ${isDark ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
<Sidebar selectedTab="stundenplan" />
|
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||||
|
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||||
|
}`}>
|
||||||
|
{/* Background blobs — same effect as /korrektur to keep the visual
|
||||||
|
language consistent across studio-v2 pages. */}
|
||||||
|
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||||
|
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||||
|
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||||
|
|
||||||
<main className="flex-1 max-w-7xl mx-auto">
|
<div className="relative z-10 p-4"><Sidebar selectedTab="stundenplan" /></div>
|
||||||
<header className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
|
||||||
Stundenplan
|
|
||||||
</h1>
|
|
||||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
|
||||||
Stammdaten und Regeln fuer den Solver
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ThemeToggle />
|
|
||||||
<LanguageDropdown />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
<main className="flex-1 relative z-10 p-6 overflow-y-auto">
|
||||||
className={`mb-4 p-3 rounded-xl border backdrop-blur-xl ${
|
<div className="max-w-7xl mx-auto">
|
||||||
isDark ? 'bg-amber-500/10 border-amber-500/30 text-amber-200' : 'bg-amber-50 border-amber-200 text-amber-900'
|
<header className="flex items-center justify-between mb-6">
|
||||||
}`}
|
<div>
|
||||||
>
|
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
<details>
|
Stundenplan
|
||||||
<summary className="cursor-pointer text-sm font-medium">
|
</h1>
|
||||||
Dev: JWT-Token setzen (Anmeldung noch nicht integriert)
|
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
</summary>
|
Stammdaten, Regeln und Plan-Generierung fuer den Schul-Stundenplan
|
||||||
<div className="mt-2 flex gap-2">
|
</p>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
onChange={e => 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'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveToken}
|
|
||||||
className="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageDropdown />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<HelpPanel />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`mb-4 p-3 rounded-xl border backdrop-blur-xl ${
|
||||||
|
isDark ? 'bg-amber-500/10 border-amber-500/30 text-amber-200' : 'bg-amber-50 border-amber-200 text-amber-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
|
Anmeldung noch nicht integriert — Dev-Token setzen
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
Bis die volle BreakPilot-Anmeldung an dieses Modul angebunden ist, muss
|
||||||
|
ein gueltiger JWT-Token manuell hinterlegt werden. Ohne Token antwortet
|
||||||
|
die API mit <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-amber-100'}`}>Authorization header required</code>.
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 opacity-90">
|
||||||
|
<li>An BreakPilot anmelden (z.B. ueber das Lehrer-Login)</li>
|
||||||
|
<li>Im Browser DevTools → Application/Storage → Cookies oder localStorage den
|
||||||
|
JWT-Token kopieren (Feldname kann je nach Login-Flow variieren)</li>
|
||||||
|
<li>Token unten einfuegen, Speichern, Seite neu laden</li>
|
||||||
|
</ol>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={e => 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'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveToken}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tokenSaved && (
|
||||||
|
<p className={`text-xs ${isDark ? 'text-emerald-300' : 'text-emerald-700'}`}>
|
||||||
|
Token gespeichert. Seite neu laden, um die Aenderung zu uebernehmen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{tab === 'plan' && <PlanHub />}
|
||||||
|
{tab === 'klassen' && <KlassenManager />}
|
||||||
|
{tab === 'lehrer' && <LehrerManager />}
|
||||||
|
{tab === 'faecher' && <FaecherManager />}
|
||||||
|
{tab === 'raeume' && <RaeumeManager />}
|
||||||
|
{tab === 'periods' && <PeriodsManager />}
|
||||||
|
{tab === 'curriculum' && <CurriculumManager />}
|
||||||
|
{tab === 'assignments' && <AssignmentsManager />}
|
||||||
|
{tab === 'regeln' && <RegelnHub />}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{TABS.map(t => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => 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}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
{tab === 'plan' && <PlanHub />}
|
|
||||||
{tab === 'klassen' && <KlassenManager />}
|
|
||||||
{tab === 'lehrer' && <LehrerManager />}
|
|
||||||
{tab === 'faecher' && <FaecherManager />}
|
|
||||||
{tab === 'raeume' && <RaeumeManager />}
|
|
||||||
{tab === 'periods' && <PeriodsManager />}
|
|
||||||
{tab === 'curriculum' && <CurriculumManager />}
|
|
||||||
{tab === 'assignments' && <AssignmentsManager />}
|
|
||||||
{tab === 'regeln' && <RegelnHub />}
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ export interface TimetableSolution {
|
|||||||
started_at?: string | null
|
started_at?: string | null
|
||||||
finished_at?: string | null
|
finished_at?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
parent_solution_id?: string | null
|
||||||
|
seconds_limit?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimetableLesson {
|
export interface TimetableLesson {
|
||||||
@@ -261,4 +263,6 @@ export interface TimetableLesson {
|
|||||||
|
|
||||||
export interface CreateTimetableSolution {
|
export interface CreateTimetableSolution {
|
||||||
name?: string
|
name?: string
|
||||||
|
parent_solution_id?: string | null
|
||||||
|
seconds_limit?: number | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ export async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
|
|||||||
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => {
|
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => {
|
||||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) })
|
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) => {
|
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => {
|
||||||
if (route.request().method() === 'DELETE') {
|
if (route.request().method() === 'DELETE') {
|
||||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' })
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' })
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ test.describe('Stundenplan — Page Shell', () => {
|
|||||||
|
|
||||||
test('page loads with title and subtitle', async ({ page }) => {
|
test('page loads with title and subtitle', async ({ page }) => {
|
||||||
await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible()
|
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 }) => {
|
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 }) => {
|
test('JWT dev field exists and persists into localStorage', async ({ page }) => {
|
||||||
await page.getByText('Dev: JWT-Token setzen').click()
|
await page.getByText('Anmeldung noch nicht integriert').click()
|
||||||
page.on('dialog', d => d.accept())
|
await page.getByPlaceholder(/Bearer-Token/).fill('test-jwt-abc')
|
||||||
await page.getByPlaceholder('Bearer-Token').fill('test-jwt-abc')
|
|
||||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt'))
|
const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt'))
|
||||||
expect(stored).toBe('test-jwt-abc')
|
expect(stored).toBe('test-jwt-abc')
|
||||||
@@ -401,3 +408,73 @@ test.describe('Stundenplan — PlanView grid', () => {
|
|||||||
await expect(page.locator('select').last()).toHaveValue('r1')
|
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('🔒')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -152,3 +152,11 @@ export const solutionsApi = {
|
|||||||
lessons: (id: string) =>
|
lessons: (id: string) =>
|
||||||
apiFetch<TimetableLesson[]>(`/timetable/solutions/${id}/lessons`),
|
apiFetch<TimetableLesson[]>(`/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 }),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from timefold.solver.domain import (
|
|||||||
planning_entity,
|
planning_entity,
|
||||||
planning_solution,
|
planning_solution,
|
||||||
PlanningVariable,
|
PlanningVariable,
|
||||||
|
PlanningPin,
|
||||||
PlanningId,
|
PlanningId,
|
||||||
PlanningEntityCollectionProperty,
|
PlanningEntityCollectionProperty,
|
||||||
ProblemFactCollectionProperty,
|
ProblemFactCollectionProperty,
|
||||||
@@ -88,6 +89,10 @@ class Lesson:
|
|||||||
Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances
|
Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances
|
||||||
with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment).
|
with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment).
|
||||||
The solver assigns timeslot + room.
|
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]
|
id: Annotated[str, PlanningId]
|
||||||
@@ -96,6 +101,7 @@ class Lesson:
|
|||||||
teacher: Teacher
|
teacher: Teacher
|
||||||
timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None)
|
timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None)
|
||||||
room: Annotated[Optional[Room], PlanningVariable] = field(default=None)
|
room: Annotated[Optional[Room], PlanningVariable] = field(default=None)
|
||||||
|
pinned: Annotated[bool, PlanningPin] = field(default=False)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.school_class}-{self.subject}#{self.id[:8]}"
|
return f"{self.school_class}-{self.subject}#{self.id[:8]}"
|
||||||
|
|||||||
@@ -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:
|
async with pool.acquire() as conn:
|
||||||
timeslots = await _load_timeslots(conn, user_id)
|
timeslots = await _load_timeslots(conn, user_id)
|
||||||
rooms = await _load_rooms(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)
|
lessons = await _build_lessons(conn, user_id, classes, subjects, teachers)
|
||||||
rules = await _load_rules(conn, user_id)
|
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(
|
return Timetable(
|
||||||
timeslots=timeslots,
|
timeslots=timeslots,
|
||||||
rooms=rooms,
|
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]:
|
async def _load_timeslots(conn: asyncpg.Connection, user_id: str) -> list[Timeslot]:
|
||||||
rows = await conn.fetch("""
|
rows = await conn.fetch("""
|
||||||
SELECT id::text, day_of_week, period_index,
|
SELECT id::text, day_of_week, period_index,
|
||||||
@@ -217,7 +278,7 @@ async def persist_solution(
|
|||||||
INSERT INTO tt_lesson
|
INSERT INTO tt_lesson
|
||||||
(solution_id, class_id, subject_id, teacher_id, room_id,
|
(solution_id, class_id, subject_id, teacher_id, room_id,
|
||||||
day_of_week, period_index, pinned)
|
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,
|
solution_id,
|
||||||
lesson.school_class.id,
|
lesson.school_class.id,
|
||||||
@@ -226,6 +287,7 @@ async def persist_solution(
|
|||||||
lesson.room.id if lesson.room else None,
|
lesson.room.id if lesson.room else None,
|
||||||
lesson.timeslot.day_of_week,
|
lesson.timeslot.day_of_week,
|
||||||
lesson.timeslot.period_index,
|
lesson.timeslot.period_index,
|
||||||
|
lesson.pinned,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,32 +35,48 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_executor = ThreadPoolExecutor(max_workers=2)
|
_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
|
||||||
_solver_factory = SolverFactory.create(
|
def _build_factory(seconds: int) -> SolverFactory:
|
||||||
SolverConfig(
|
"""One factory per solve so we can honour per-job timeout overrides.
|
||||||
solution_class=Timetable,
|
Cheap to construct — the Java side caches what it can across builds."""
|
||||||
entity_class_list=[Lesson],
|
return SolverFactory.create(
|
||||||
score_director_factory_config=ScoreDirectorFactoryConfig(
|
SolverConfig(
|
||||||
constraint_provider_function=define_constraints,
|
solution_class=Timetable,
|
||||||
),
|
entity_class_list=[Lesson],
|
||||||
termination_config=TerminationConfig(
|
score_director_factory_config=ScoreDirectorFactoryConfig(
|
||||||
spent_limit=Duration(seconds=settings.solver_seconds_limit),
|
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."""
|
"""Blocking solver call; runs in a worker thread."""
|
||||||
solver = _solver_factory.build_solver()
|
solver = _build_factory(seconds).build_solver()
|
||||||
return solver.solve(problem)
|
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:
|
async def run_solve(solution_id: str, user_id: str) -> None:
|
||||||
"""Top-level async entry. Caller fires-and-forgets via BackgroundTasks."""
|
"""Top-level async entry. Caller fires-and-forgets via BackgroundTasks."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
try:
|
try:
|
||||||
|
parent_id, seconds = await _fetch_solve_options(pool, solution_id)
|
||||||
await mark_running(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:
|
if not problem.lessons:
|
||||||
await mark_failed(pool, solution_id,
|
await mark_failed(pool, solution_id,
|
||||||
@@ -76,7 +92,7 @@ async def run_solve(solution_id: str, user_id: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
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
|
score = solved.score
|
||||||
hard = score.hard_score() if score else 0
|
hard = score.hard_score() if score else 0
|
||||||
|
|||||||
Reference in New Issue
Block a user