diff --git a/.gitignore b/.gitignore index 849db77..47af842 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ /docs/SDLC.md /scripts/cowork-bootstrap.sh /planning/ +isley +data* diff --git a/handlers/plant.go b/handlers/plant.go index 0d5f835..f2b2f76 100644 --- a/handlers/plant.go +++ b/handlers/plant.go @@ -313,6 +313,21 @@ func DeletePlantById(db *sql.DB, id string) error { } defer tx.Rollback() // no-op after Commit + if err := deletePlantByIDTx(tx, id); err != nil { + fieldLogger.WithError(err).Error("Failed to delete plant") + return err + } + + if err := tx.Commit(); err != nil { + fieldLogger.WithError(err).Error("Failed to commit delete transaction") + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func deletePlantByIDTx(tx *sql.Tx, id string) error { + fieldLogger := logger.Log.WithField("func", "deletePlantByIDTx") + // Delete child records first, then the plant itself. deletes := []struct { table string @@ -330,11 +345,6 @@ func DeletePlantById(db *sql.DB, id string) error { return fmt.Errorf("failed to delete from %s: %w", d.table, err) } } - - if err := tx.Commit(); err != nil { - fieldLogger.WithError(err).Error("Failed to commit delete transaction") - return fmt.Errorf("failed to commit transaction: %w", err) - } return nil } diff --git a/handlers/strain.go b/handlers/strain.go index 7ca7af1..6889079 100644 --- a/handlers/strain.go +++ b/handlers/strain.go @@ -17,6 +17,14 @@ import ( // Breeder helpers & handlers // --------------------------------------------------------------------------- +// createBreeder inserts a new breeder into the database and returns the new ID. +// The transaction is expected to be managed by the caller +func createBreeder(tx *sql.Tx, name string) (int, error) { + var id int + err := tx.QueryRow("INSERT INTO breeder (name) VALUES ($1) RETURNING id", name).Scan(&id) + return id, err +} + func GetBreeders(db *sql.DB) []types.Breeder { fieldLogger := logger.Log.WithField("func", "GetBreeders") @@ -57,18 +65,30 @@ func AddBreederHandler(c *gin.Context) { // Add breeder to database db := DBFromContext(c) + tx, err := db.Begin() + if err != nil { + fieldLogger.WithError(err).Error("Failed to start breeder add transaction") + apiInternalError(c, "api_failed_to_start_tx") + return + } + defer tx.Rollback() // Insert new breeder and return new id - var id int - err := db.QueryRow("INSERT INTO breeder (name) VALUES ($1) RETURNING id", breeder.Name).Scan(&id) + var breederID int + breederID, err = createBreeder(tx, strainInput.NewBreeder) if err != nil { - fieldLogger.WithError(err).Error("Failed to add breeder") + fieldLogger.WithError(err).Error("Failed to create new breeder") apiInternalError(c, "api_failed_to_add_breeder") return } - ConfigStoreFromContext(c).AppendBreeder(types.Breeder{ID: id, Name: breeder.Name}) + if err := tx.Commit(); err != nil { + fieldLogger.WithError(err).Error("Failed to commit strain add transaction") + apiInternalError(c, "api_failed_to_commit_tx") + return + } + ConfigStoreFromContext(c).AppendBreeder(types.Breeder{ID: breederID, Name: breeder.Name}) - c.JSON(http.StatusCreated, gin.H{"id": id}) + c.JSON(http.StatusCreated, gin.H{"id": breederID}) } func UpdateBreederHandler(c *gin.Context) { fieldLogger := logger.Log.WithField("func", "UpdateBreederHandler") @@ -109,18 +129,22 @@ func DeleteBreederHandler(c *gin.Context) { // Delete breeder from database db := DBFromContext(c) - - // Delete any plants associated with this breeder - rows, err := db.Query("SELECT p.id FROM plant p LEFT OUTER JOIN strain s on s.id = p.strain_id WHERE s.breeder_id = $1", id) + tx, err := db.Begin() if err != nil { - if err.Error() != "sql: no rows in result set" { + fieldLogger.WithError(err).Error("Failed to start breeder delete transaction") + apiInternalError(c, "api_failed_to_start_tx") + return + } + defer tx.Rollback() - } else { - fieldLogger.WithError(err).Error("Failed to delete plants") - return - } + // Query plants associated with this breeder to be deleted + // This is two step to be able to reuse the deletePlantByID helper which will ensure cleanup of related records + rows, err := tx.Query("SELECT p.id FROM plant p LEFT OUTER JOIN strain s on s.id = p.strain_id WHERE s.breeder_id = $1", id) + if err != nil { + fieldLogger.WithError(err).Error("Failed to load breeder plants") + apiInternalError(c, "api_failed_to_delete_plant") + return } - defer rows.Close() plantList := []int{} for rows.Next() { @@ -132,28 +156,47 @@ func DeleteBreederHandler(c *gin.Context) { } plantList = append(plantList, plantId) } + rows.Close() + if err := rows.Err(); err != nil { + fieldLogger.WithError(err).Error("Failed to iterate breeder plants") + apiInternalError(c, "api_failed_to_delete_plant") + return + } for _, plantId := range plantList { - DeletePlantById(db, strconv.Itoa(plantId)) + if err := deletePlantByIDTx(tx, strconv.Itoa(plantId)); err != nil { + fieldLogger.WithError(err).WithField("plantID", plantId).Error("Failed to delete plant") + apiInternalError(c, "api_failed_to_delete_plant") + return + } } // Delete any strains associated with this breeder - _, err = db.Exec("DELETE FROM strain WHERE breeder_id = $1", id) + _, err = tx.Exec("DELETE FROM strain WHERE breeder_id = $1", id) if err != nil { fieldLogger.WithError(err).Error("Failed to delete strains") apiInternalError(c, "api_failed_to_delete_strains") + return } // Delete breeder from database - _, err = db.Exec("DELETE FROM breeder WHERE id = $1", id) + _, err = tx.Exec("DELETE FROM breeder WHERE id = $1", id) if err != nil { fieldLogger.WithError(err).Error("Failed to delete breeder") apiInternalError(c, "api_failed_to_delete_breeder") return } + if err := tx.Commit(); err != nil { + fieldLogger.WithError(err).Error("Failed to commit breeder delete transaction") + apiInternalError(c, "api_failed_to_commit_tx") + return + } + //Reload Config - ConfigStoreFromContext(c).SetBreeders(GetBreeders(db)) + store := ConfigStoreFromContext(c) + store.SetBreeders(GetBreeders(db)) + store.SetStrains(GetStrains(db)) apiOK(c, "api_breeder_deleted") } @@ -161,6 +204,20 @@ func DeleteBreederHandler(c *gin.Context) { // --------------------------------------------------------------------------- // Strain helpers & handlers // --------------------------------------------------------------------------- +var strainInput struct { + Name string `json:"name"` + BreederID *int `json:"breeder_id"` // Nullable for new breeders + NewBreeder string `json:"new_breeder"` + Indica int `json:"indica"` + Sativa int `json:"sativa"` + Ruderalis int `json:"ruderalis"` + Autoflower bool `json:"autoflower"` + SeedCount int `json:"seed_count"` + Description string `json:"description"` + ShortDescription string `json:"short_desc"` + CycleTime int `json:"cycle_time"` + Url string `json:"url"` +} func validateStrainFields(name, description, shortDesc, newBreeder, strainURL string) error { if err := utils.ValidateRequiredString("name", name, utils.MaxNameLength); err != nil { @@ -181,7 +238,13 @@ func validateStrainFields(name, description, shortDesc, newBreeder, strainURL st func GetStrains(db *sql.DB) []types.Strain { fieldLogger := logger.Log.WithField("func", "GetStrains") - rows, err := db.Query("SELECT s.id, s.name, b.id as breeder_id, b.name as breeder, s.indica, s.sativa, s.autoflower, s.description, coalesce(s.short_desc, ''), s.seed_count FROM strain s left outer join breeder b on s.breeder_id = b.id ORDER BY s.name ASC") + rows, err := db.Query(` + SELECT s.id, s.name, b.id AS breeder_id, b.name AS breeder, + s.indica, s.sativa, s.ruderalis, s.autoflower, + s.description, COALESCE(s.short_desc, ''), s.seed_count + FROM strain s + LEFT OUTER JOIN breeder b ON s.breeder_id = b.id + ORDER BY s.name ASC`) if err != nil { fieldLogger.WithError(err).Error("Failed to query strains") return nil @@ -190,7 +253,7 @@ func GetStrains(db *sql.DB) []types.Strain { var strains []types.Strain for rows.Next() { var strain types.Strain - err = rows.Scan(&strain.ID, &strain.Name, &strain.BreederID, &strain.Breeder, &strain.Indica, &strain.Sativa, &strain.Autoflower, &strain.Description, &strain.ShortDescription, &strain.SeedCount) + err = rows.Scan(&strain.ID, &strain.Name, &strain.BreederID, &strain.Breeder, &strain.Indica, &strain.Sativa, &strain.Ruderalis, &strain.Autoflower, &strain.Description, &strain.ShortDescription, &strain.SeedCount) if err != nil { fieldLogger.WithError(err).Error("Failed to scan strain") return nil @@ -207,11 +270,19 @@ func GetStrain(db *sql.DB, id string) types.Strain { var strain types.Strain //join in breeder name err := db.QueryRow(` - SELECT s.id, s.name, coalesce(s.short_desc, ''), b.name AS breeder, b.id as breeder_id, s.indica, s.sativa, s.autoflower, s.seed_count, s.description, coalesce(s.cycle_time, 0), coalesce(s.url, '') + SELECT s.id, s.name, COALESCE(s.short_desc, ''), + b.name AS breeder, b.id AS breeder_id, + s.indica, s.sativa, s.ruderalis, s.autoflower, + s.seed_count, s.description, + COALESCE(s.cycle_time, 0), COALESCE(s.url, '') FROM strain s JOIN breeder b ON s.breeder_id = b.id WHERE s.id = $1`, id).Scan( - &strain.ID, &strain.Name, &strain.ShortDescription, &strain.Breeder, &strain.BreederID, &strain.Indica, &strain.Sativa, &strain.Autoflower, &strain.SeedCount, &strain.Description, &strain.CycleTime, &strain.Url) + &strain.ID, &strain.Name, &strain.ShortDescription, + &strain.Breeder, &strain.BreederID, + &strain.Indica, &strain.Sativa, &strain.Ruderalis, &strain.Autoflower, + &strain.SeedCount, &strain.Description, + &strain.CycleTime, &strain.Url) if err != nil { if err == sql.ErrNoRows { fieldLogger.Error("Strain not found") @@ -228,35 +299,21 @@ func GetStrain(db *sql.DB, id string) types.Strain { func AddStrainHandler(c *gin.Context) { fieldLogger := logger.Log.WithField("func", "AddStrainHandler") // Parse the incoming JSON request - var req struct { - Name string `json:"name"` - BreederID *int `json:"breeder_id"` // Nullable for new breeders - NewBreeder string `json:"new_breeder"` - Indica int `json:"indica"` - Sativa int `json:"sativa"` - Autoflower bool `json:"autoflower"` - SeedCount int `json:"seed_count"` - Description string `json:"description"` - ShortDescription string `json:"short_desc"` - CycleTime int `json:"cycle_time"` - Url string `json:"url"` - } - - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBindJSON(&strainInput); err != nil { fieldLogger.WithError(err).Error("Failed to bind JSON") apiBadRequest(c, "api_invalid_request_payload") return } - if err := validateStrainFields(req.Name, req.Description, req.ShortDescription, req.NewBreeder, req.Url); err != nil { + if err := validateStrainFields(strainInput.Name, strainInput.Description, strainInput.ShortDescription, strainInput.NewBreeder, strainInput.Url); err != nil { apiBadRequest(c, err.Error()) return } - // Validate Indica and Sativa sum - if req.Indica+req.Sativa != 100 { - fieldLogger.Error("Indica and Sativa must sum to 100") - apiBadRequest(c, "api_indica_sativa_must_sum_100") + // Validate Indica, Sativa, and Ruderalis sum + if strainInput.Indica+strainInput.Sativa+strainInput.Ruderalis != 100 { + fieldLogger.Error("Indica, Sativa, and Ruderalis must sum to 100") + apiBadRequest(c, "api_genetics_must_sum_100") return } @@ -264,53 +321,64 @@ func AddStrainHandler(c *gin.Context) { db := DBFromContext(c) store := ConfigStoreFromContext(c) + tx, err := db.Begin() + if err != nil { + fieldLogger.WithError(err).Error("Failed to start strain add transaction") + apiInternalError(c, "api_failed_to_start_tx") + return + } + defer tx.Rollback() + // Check for new breeder and insert if needed var breederID int - if req.BreederID == nil { - if req.NewBreeder == "" { + if strainInput.BreederID == nil { + if strainInput.NewBreeder == "" { fieldLogger.Error("New breeder name is required") apiBadRequest(c, "api_new_breeder_name_required") return } - - // Insert new breeder - insertBreederStmt := ` - INSERT INTO breeder (name) - VALUES ($1) - RETURNING id` - err := db.QueryRow(insertBreederStmt, req.NewBreeder).Scan(&breederID) + breederID, err = createBreeder(tx, strainInput.NewBreeder) if err != nil { - fieldLogger.WithError(err).Error("Failed to insert new breeder") - apiInternalError(c, "api_failed_to_add_new_breeder") + fieldLogger.WithError(err).Error("Failed to create new breeder") + apiInternalError(c, "api_failed_to_add_breeder") return } - store.SetBreeders(GetBreeders(db)) } else { // Use existing breeder ID - breederID = *req.BreederID + breederID = *strainInput.BreederID } // Insert the new strain into the database stmt := ` - INSERT INTO strain (name, breeder_id, indica, sativa, autoflower, seed_count, description, cycle_time, url, short_desc) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id + INSERT INTO strain ( + name, breeder_id, indica, sativa, ruderalis, + autoflower, seed_count, description, cycle_time, url, short_desc + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id ` //convert autoflower to int var autoflowerInt int - if req.Autoflower { + if strainInput.Autoflower { autoflowerInt = 1 } else { autoflowerInt = 0 } var id int - err := db.QueryRow(stmt, req.Name, breederID, req.Indica, req.Sativa, autoflowerInt, req.SeedCount, req.Description, req.CycleTime, req.Url, req.ShortDescription).Scan(&id) - if err != nil { + if err := tx.QueryRow(stmt, strainInput.Name, breederID, strainInput.Indica, strainInput.Sativa, strainInput.Ruderalis, autoflowerInt, strainInput.SeedCount, strainInput.Description, strainInput.CycleTime, strainInput.Url, strainInput.ShortDescription).Scan(&id); err != nil { fieldLogger.WithError(err).Error("Failed to insert strain") apiInternalError(c, "api_failed_to_add_strain") return } + if err := tx.Commit(); err != nil { + fieldLogger.WithError(err).Error("Failed to commit strain add transaction") + apiInternalError(c, "api_failed_to_commit_tx") + return + } + + store.SetBreeders(GetBreeders(db)) store.SetStrains(GetStrains(db)) // Respond with success @@ -332,11 +400,19 @@ func GetStrainHandler(c *gin.Context) { var strain types.Strain err = db.QueryRow(` - SELECT s.id, s.name, b.name as breeder, b.id as breeder_id, s.indica, s.sativa, s.autoflower, s.description, coalesce(s.short_desc, ''), s.seed_count, coalesce(s.cycle_time, 0), coalesce(s.url, '') - FROM strain s LEFT OUTER JOIN breeder b on s.breeder_id = b.id - WHERE s.id = $1`, id).Scan( - &strain.ID, &strain.Name, &strain.Breeder, &strain.BreederID, &strain.Indica, &strain.Sativa, - &strain.Autoflower, &strain.Description, &strain.ShortDescription, &strain.SeedCount, &strain.CycleTime, &strain.Url) + SELECT s.id, s.name, + b.name AS breeder, b.id AS breeder_id, + s.indica, s.sativa, s.ruderalis, s.autoflower, + s.description, COALESCE(s.short_desc, ''), s.seed_count, + COALESCE(s.cycle_time, 0), COALESCE(s.url, '') + FROM strain s + LEFT OUTER JOIN breeder b ON s.breeder_id = b.id + WHERE s.id = $1`, id).Scan( + &strain.ID, &strain.Name, + &strain.Breeder, &strain.BreederID, + &strain.Indica, &strain.Sativa, &strain.Ruderalis, &strain.Autoflower, + &strain.Description, &strain.ShortDescription, &strain.SeedCount, + &strain.CycleTime, &strain.Url) if err != nil { if err == sql.ErrNoRows { apiNotFound(c, "api_strain_not_found") @@ -359,89 +435,93 @@ func UpdateStrainHandler(c *gin.Context) { return } - var req struct { - Name string `json:"name"` - BreederID *int `json:"breeder_id"` // Nullable for new breeders - NewBreeder string `json:"new_breeder"` - Indica int `json:"indica"` - Sativa int `json:"sativa"` - Autoflower bool `json:"autoflower"` - Description string `json:"description"` - ShortDescription string `json:"short_desc"` - SeedCount int `json:"seed_count"` - CycleTime int `json:"cycle_time"` - Url string `json:"url"` - } - - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBindJSON(&strainInput); err != nil { fieldLogger.WithError(err).Error("Failed to bind JSON") apiBadRequest(c, "api_invalid_request_body") return } - if err := validateStrainFields(req.Name, req.Description, req.ShortDescription, req.NewBreeder, req.Url); err != nil { + if err := validateStrainFields(strainInput.Name, strainInput.Description, strainInput.ShortDescription, strainInput.NewBreeder, strainInput.Url); err != nil { apiBadRequest(c, err.Error()) return } - // Validate Indica and Sativa sum - if req.Indica+req.Sativa != 100 { - fieldLogger.Error("Indica and Sativa must sum to 100") - apiBadRequest(c, "api_indica_sativa_must_sum_100") + // Validate Indica, Sativa, and Ruderalis sum + if strainInput.Indica+strainInput.Sativa+strainInput.Ruderalis != 100 { + fieldLogger.Error("Indica, Sativa, and Ruderalis must sum to 100") + apiBadRequest(c, "api_genetics_must_sum_100") return } // Open the database db := DBFromContext(c) + tx, err := db.Begin() + if err != nil { + fieldLogger.WithError(err).Error("Failed to start strain update transaction") + apiInternalError(c, "api_failed_to_start_tx") + return + } + defer tx.Rollback() + // Determine the breeder ID var breederID int - if req.BreederID == nil { - if req.NewBreeder == "" { + if strainInput.BreederID == nil { + if strainInput.NewBreeder == "" { fieldLogger.Error("New breeder name is required") apiBadRequest(c, "api_new_breeder_name_required") return } - // Insert the new breeder into the database - insertBreederStmt := ` - INSERT INTO breeder (name) - VALUES ($1) - RETURNING id - ` - err := db.QueryRow(insertBreederStmt, req.NewBreeder).Scan(&breederID) + breederID, err = createBreeder(tx, strainInput.NewBreeder) if err != nil { - fieldLogger.WithError(err).Error("Failed to insert new breeder") - apiInternalError(c, "api_failed_to_add_new_breeder") + fieldLogger.WithError(err).Error("Failed to create new breeder") + apiInternalError(c, "api_failed_to_add_breeder") return } ConfigStoreFromContext(c).SetBreeders(GetBreeders(db)) } else { - breederID = *req.BreederID + breederID = *strainInput.BreederID } // Update the strain in the database updateStmt := ` - UPDATE strain - SET name = $1, breeder_id = $2, indica = $3, sativa = $4, autoflower = $5, description = $6, seed_count = $7, cycle_time = $8, url = $9, short_desc = $10 - WHERE id = $11 - ` + UPDATE strain + SET name = $1, + breeder_id = $2, + indica = $3, + sativa = $4, + ruderalis = $5, + autoflower = $6, + description = $7, + seed_count = $8, + cycle_time = $9, + url = $10, + short_desc = $11 + WHERE id = $12 + ` //Convert autoflower to int var autoflowerInt int - if req.Autoflower { + if strainInput.Autoflower { autoflowerInt = 1 } else { autoflowerInt = 0 } - _, err = db.Exec(updateStmt, req.Name, breederID, req.Indica, req.Sativa, - autoflowerInt, req.Description, req.SeedCount, req.CycleTime, req.Url, req.ShortDescription, id) + _, err = tx.Exec(updateStmt, strainInput.Name, breederID, strainInput.Indica, strainInput.Sativa, strainInput.Ruderalis, + autoflowerInt, strainInput.Description, strainInput.SeedCount, strainInput.CycleTime, strainInput.Url, strainInput.ShortDescription, id) if err != nil { fieldLogger.WithError(err).Error("Failed to update strain") apiInternalError(c, "api_failed_to_update_strain") return } + if err := tx.Commit(); err != nil { + fieldLogger.WithError(err).Error("Failed to commit strain update transaction") + apiInternalError(c, "api_failed_to_commit_tx") + return + } + apiOK(c, "api_strain_updated") } @@ -505,16 +585,22 @@ func getStrainsBySeedCount(db *sql.DB, inStock bool) ([]types.Strain, error) { } query := ` - SELECT s.id, s.name, b.name AS breeder, b.id as breeder_id, - s.indica, s.sativa, s.autoflower, s.seed_count, s.description, - coalesce(s.short_desc, ''), coalesce(s.cycle_time, 0), coalesce(s.url, ''), + SELECT s.id, s.name, + b.name AS breeder, b.id AS breeder_id, + s.indica, s.sativa, s.ruderalis, s.autoflower, + s.seed_count, s.description, + COALESCE(s.short_desc, ''), + COALESCE(s.cycle_time, 0), + COALESCE(s.url, ''), ` + aggExpr + ` FROM strain s JOIN breeder b ON s.breeder_id = b.id LEFT JOIN strain_lineage sl ON sl.strain_id = s.id WHERE s.seed_count ` + op + ` 0 - GROUP BY s.id, s.name, b.name, b.id, s.indica, s.sativa, s.autoflower, - s.seed_count, s.description, s.short_desc, s.cycle_time, s.url + GROUP BY s.id, s.name, b.name, b.id, + s.indica, s.sativa, s.ruderalis, s.autoflower, + s.seed_count, s.description, s.short_desc, + s.cycle_time, s.url ORDER BY s.name ASC ` @@ -529,7 +615,7 @@ func getStrainsBySeedCount(db *sql.DB, inStock bool) ([]types.Strain, error) { for rows.Next() { var strain types.Strain if err := rows.Scan(&strain.ID, &strain.Name, &strain.Breeder, &strain.BreederID, - &strain.Indica, &strain.Sativa, &strain.Autoflower, &strain.SeedCount, + &strain.Indica, &strain.Sativa, &strain.Ruderalis, &strain.Autoflower, &strain.SeedCount, &strain.Description, &strain.ShortDescription, &strain.CycleTime, &strain.Url, &strain.Lineage); err != nil { fieldLogger.WithError(err).Error("Failed to scan strain") diff --git a/handlers/strain_http_test.go b/handlers/strain_http_test.go index cf0fcf5..8e8e801 100644 --- a/handlers/strain_http_test.go +++ b/handlers/strain_http_test.go @@ -234,9 +234,10 @@ func TestStrainHTTP_AddStrain_RejectsMissingBreederWhenNoNewBreeder(t *testing.T c := server.NewClient(t) resp, err := c.Do(testutil.APIReq(t, http.MethodPost, c.BaseURL+"/strains", apiKey, testutil.JSONBody(t, map[string]interface{}{ - "name": "Nameless", - "indica": 50, - "sativa": 50, + "name": "Nameless", + "indica": 50, + "sativa": 50, + "ruderalis": 0, // breeder_id == nil and new_breeder == "" → should be 400 }), "application/json")) require.NoError(t, err) diff --git a/model/migrations/postgres/019_ruderalis.postgres.down.sql b/model/migrations/postgres/019_ruderalis.postgres.down.sql new file mode 100644 index 0000000..8866b4b --- /dev/null +++ b/model/migrations/postgres/019_ruderalis.postgres.down.sql @@ -0,0 +1 @@ +ALTER TABLE strain DROP COLUMN ruderalis; diff --git a/model/migrations/postgres/019_ruderalis.postgres.up.sql b/model/migrations/postgres/019_ruderalis.postgres.up.sql new file mode 100644 index 0000000..e626e5f --- /dev/null +++ b/model/migrations/postgres/019_ruderalis.postgres.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE strain ADD COLUMN ruderalis INTEGER NOT NULL DEFAULT 0; +UPDATE strain SET ruderalis = 0 WHERE ruderalis IS NULL; diff --git a/model/migrations/sqlite/019_ruderalis.sqlite.down.sql b/model/migrations/sqlite/019_ruderalis.sqlite.down.sql new file mode 100644 index 0000000..8866b4b --- /dev/null +++ b/model/migrations/sqlite/019_ruderalis.sqlite.down.sql @@ -0,0 +1 @@ +ALTER TABLE strain DROP COLUMN ruderalis; diff --git a/model/migrations/sqlite/019_ruderalis.sqlite.up.sql b/model/migrations/sqlite/019_ruderalis.sqlite.up.sql new file mode 100644 index 0000000..e626e5f --- /dev/null +++ b/model/migrations/sqlite/019_ruderalis.sqlite.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE strain ADD COLUMN ruderalis INTEGER NOT NULL DEFAULT 0; +UPDATE strain SET ruderalis = 0 WHERE ruderalis IS NULL; diff --git a/model/types/base_models.go b/model/types/base_models.go index 8741dea..5887a75 100644 --- a/model/types/base_models.go +++ b/model/types/base_models.go @@ -209,6 +209,7 @@ type Strain struct { BreederID int `json:"breeder_id"` Indica int `json:"indica"` Sativa int `json:"sativa"` + Ruderalis int `json:"ruderalis"` Autoflower bool `json:"autoflower"` Description string `json:"description"` SeedCount int `json:"seed_count"` diff --git a/tests/integration/strain_test.go b/tests/integration/strain_test.go index 9cae044..0fffdb5 100644 --- a/tests/integration/strain_test.go +++ b/tests/integration/strain_test.go @@ -93,6 +93,7 @@ func TestStrain_AddCreatesNewBreeder(t *testing.T) { "new_breeder": "Brand New Breeder", "indica": 50, "sativa": 50, + "ruderalis": 0, "autoflower": true, "seed_count": 0, "description": "", @@ -141,6 +142,7 @@ func TestStrain_AddRejectsMissingBreeder(t *testing.T) { "name": "Orphan", "indica": 50, "sativa": 50, + "ruderalis": 0, "autoflower": false, "seed_count": 0, }) diff --git a/utils/locales/de.yaml b/utils/locales/de.yaml index 3eaea23..d98ac33 100644 --- a/utils/locales/de.yaml +++ b/utils/locales/de.yaml @@ -74,7 +74,7 @@ strain_delete_fail: "Löschen der Sorte fehlgeschlagen." strain_delete_error: "Fehler beim Löschen der Sorte: " title_auto: "Auto" title_is: "I/S" -i_s_ratio: "Indica / Sativa Verhältnis" +i_s_ratio: "Indica / Sativa / Ruderalis Verhältnis" na: "N/A" seed_count: "Samenanzahl" seed_count_placeholder: "Samenanzahl eingeben" @@ -514,7 +514,6 @@ api_ecowitt_sensors_scanned: "EcoWitt-Sensoren gescannt und hinzugefügt" api_failed_to_add_activity: "Aktivität konnte nicht hinzugefügt werden" api_failed_to_add_breeder: "Züchter konnte nicht hinzugefügt werden" api_failed_to_add_metric: "Metrik konnte nicht hinzugefügt werden" -api_failed_to_add_new_breeder: "Neuer Züchter konnte nicht hinzugefügt werden" api_failed_to_add_strain: "Sorte konnte nicht hinzugefügt werden" api_failed_to_add_stream: "Stream konnte nicht hinzugefügt werden" api_failed_to_add_zone: "Zone konnte nicht hinzugefügt werden" @@ -580,7 +579,7 @@ api_failed_to_commit_tx: "Datenbanktransaktion konnte nicht abgeschlossen werden api_image_deleted: "Bild erfolgreich gelöscht" api_image_not_found: "Bild nicht gefunden" api_images_uploaded: "Bilder erfolgreich hochgeladen" -api_indica_sativa_must_sum_100: "Indica und Sativa müssen zusammen 100 ergeben" +api_genetics_must_sum_100: "Indica, Sativa und Ruderalis müssen zusammen 100 ergeben" api_internal_server_error: "Interner Serverfehler" api_invalid_file_type: "Ungültiger Dateityp; nur JPEG, PNG, GIF und WebP sind erlaubt" api_invalid_image_id: "Ungültige Bild-ID" diff --git a/utils/locales/en.yaml b/utils/locales/en.yaml index fe581b0..bb60a3f 100644 --- a/utils/locales/en.yaml +++ b/utils/locales/en.yaml @@ -74,7 +74,7 @@ strain_delete_fail: "Failed to delete strain" strain_delete_error: "Error deleting strain: " title_auto: "Auto" title_is: "I/S" -i_s_ratio: "Indica / Sativa Ratio" +i_s_ratio: "Indica / Sativa / Ruderalis Ratio" na: "N/A" seed_count: "Seed Count" seed_count_placeholder: "Enter seed count" @@ -519,7 +519,6 @@ api_ecowitt_sensors_scanned: "EcoWitt sensors scanned and added" api_failed_to_add_activity: "Failed to add activity" api_failed_to_add_breeder: "Failed to add breeder" api_failed_to_add_metric: "Failed to add metric" -api_failed_to_add_new_breeder: "Failed to add new breeder" api_failed_to_add_strain: "Failed to add strain" api_failed_to_add_stream: "Failed to add stream" api_failed_to_add_zone: "Failed to add zone" @@ -585,7 +584,7 @@ api_failed_to_commit_tx: "Failed to commit database transaction" api_image_deleted: "Image deleted successfully" api_image_not_found: "Image not found" api_images_uploaded: "Images uploaded successfully" -api_indica_sativa_must_sum_100: "Indica and Sativa must sum to 100" +api_genetics_must_sum_100: "Indica, Sativa, and Ruderalis must sum to 100" api_internal_server_error: "Internal server error" api_invalid_file_type: "Invalid file type; only JPEG, PNG, GIF, and WebP are allowed" api_invalid_image_id: "Invalid image ID" diff --git a/utils/locales/es.yaml b/utils/locales/es.yaml index 792c52a..590e681 100644 --- a/utils/locales/es.yaml +++ b/utils/locales/es.yaml @@ -74,7 +74,7 @@ strain_delete_fail: "No se pudo eliminar la variedad" strain_delete_error: "Error al eliminar la variedad: " title_auto: "Auto" title_is: "I/S" -i_s_ratio: "Proporción Índica / Sativa" +i_s_ratio: "Proporción Índica / Sativa / Ruderalis" na: "N/D" seed_count: "Cantidad de Semillas" seed_count_placeholder: "Ingrese la cantidad de semillas" @@ -512,7 +512,6 @@ api_ecowitt_sensors_scanned: "Sensores EcoWitt escaneados y añadidos" api_failed_to_add_activity: "No se pudo agregar la actividad" api_failed_to_add_breeder: "No se pudo agregar el criador" api_failed_to_add_metric: "No se pudo agregar la métrica" -api_failed_to_add_new_breeder: "No se pudo agregar el nuevo criador" api_failed_to_add_strain: "No se pudo agregar la variedad" api_failed_to_add_stream: "No se pudo agregar la transmisión" api_failed_to_add_zone: "No se pudo agregar la zona" @@ -578,7 +577,7 @@ api_failed_to_commit_tx: "No se pudo confirmar la transacción de base de datos" api_image_deleted: "Imagen eliminada correctamente" api_image_not_found: "Imagen no encontrada" api_images_uploaded: "Imágenes subidas correctamente" -api_indica_sativa_must_sum_100: "Índica y Sativa deben sumar 100" +api_genetics_must_sum_100: "Índica, Sativa y Ruderalis deben sumar 100" api_internal_server_error: "Error interno del servidor" api_invalid_file_type: "Tipo de archivo no válido; solo se permiten JPEG, PNG, GIF y WebP" api_invalid_image_id: "ID de imagen no válido" diff --git a/utils/locales/fr.yaml b/utils/locales/fr.yaml index 4a6da85..cd2ce61 100644 --- a/utils/locales/fr.yaml +++ b/utils/locales/fr.yaml @@ -93,7 +93,7 @@ strain_delete_fail: "Échec de la suppression de la variété" strain_delete_error: "Erreur lors de la suppression de la variété : " title_auto: "Auto" title_is: "I/S" -i_s_ratio: "Ratio Indica / Sativa" +i_s_ratio: "Ratio Indica / Sativa / Ruderalis" na: "N/A" seed_count: "Nombre de graines" seed_count_placeholder: "Entrez le nombre de graines" @@ -512,7 +512,6 @@ api_ecowitt_sensors_scanned: "Capteurs EcoWitt scannés et ajoutés" api_failed_to_add_activity: "Impossible d'ajouter l'activité" api_failed_to_add_breeder: "Impossible d'ajouter l'éleveur" api_failed_to_add_metric: "Impossible d'ajouter la métrique" -api_failed_to_add_new_breeder: "Impossible d'ajouter le nouvel éleveur" api_failed_to_add_strain: "Impossible d'ajouter la variété" api_failed_to_add_stream: "Impossible d'ajouter le flux" api_failed_to_add_zone: "Impossible d'ajouter la zone" @@ -578,7 +577,7 @@ api_failed_to_commit_tx: "Impossible de valider la transaction de base de donné api_image_deleted: "Image supprimée avec succès" api_image_not_found: "Image non trouvée" api_images_uploaded: "Images téléversées avec succès" -api_indica_sativa_must_sum_100: "Indica et Sativa doivent totaliser 100" +api_genetics_must_sum_100: "Indica, Sativa et Ruderalis doivent totaliser 100" api_internal_server_error: "Erreur interne du serveur" api_invalid_file_type: "Type de fichier non valide ; seuls JPEG, PNG, GIF et WebP sont autorisés" api_invalid_image_id: "ID d'image non valide" diff --git a/web/static/css/isley.css b/web/static/css/isley.css index a9c7069..b74b021 100644 --- a/web/static/css/isley.css +++ b/web/static/css/isley.css @@ -865,18 +865,130 @@ header .nav-link.active::after { background: linear-gradient(90deg, #7c3aed, #a855f7); transition: width 0.3s ease; } +/* Tri-color ratio bar (Indica / Sativa / Ruderalis) */ +.strain-ratio-bar-tri { + height: 10px; + border-radius: 999px; + overflow: hidden; + display: flex; + background: #e5e7eb; +} +.strain-ratio-indica { + height: 100%; + background: linear-gradient(90deg, #7c3aed, #a855f7); + transition: width 0.3s ease; +} +.strain-ratio-sativa { + height: 100%; + background: linear-gradient(90deg, #16a34a, #22c55e); + transition: width 0.3s ease; +} +.strain-ratio-ruderalis { + height: 100%; + background: linear-gradient(90deg, #d97706, #f59e0b); + transition: width 0.3s ease; +} +/* Mini tri-color ratio bar for cards/tables */ +.sc-ratio-mini-tri { + display: flex; + overflow: hidden; +} +.sc-ratio-mini-indica { + height: 100%; + background: #a855f7; +} +.sc-ratio-mini-sativa { + height: 100%; + background: #22c55e; +} +.sc-ratio-mini-ruderalis { + height: 100%; + background: #f59e0b; +} .strain-indica-text { color: #a855f7; } .strain-sativa-text { color: #22c55e; } +.strain-ruderalis-text { + color: #d97706; +} + +.strain-genetics-slider { + position: relative; + height: 24px; +} + +.strain-genetics-slider .strain-ratio-bar-tri { + position: absolute; + inset: 7px 0; + height: 10px; +} + +.strain-genetics-thumb { + -webkit-appearance: none; + appearance: none; + position: absolute; + inset: 0; + width: 100%; + margin: 0; + background: transparent; + pointer-events: none; +} + +.strain-genetics-thumb::-webkit-slider-runnable-track { + height: 24px; + background: transparent; +} + +.strain-genetics-thumb::-moz-range-track { + height: 24px; + background: transparent; +} + +.strain-genetics-thumb::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #fff; + background: #334155; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.3); + margin-top: 4px; + pointer-events: auto; + cursor: pointer; +} + +.strain-genetics-thumb::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #fff; + background: #334155; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.3); + pointer-events: auto; + cursor: pointer; +} + +.strain-genetics-thumb-lower { + z-index: 3; +} + +.strain-genetics-thumb-upper { + z-index: 4; +} + [data-bs-theme="dark"] .strain-indica-text { color: #c4b5fd; } [data-bs-theme="dark"] .strain-sativa-text { color: #6ee7b7; } +[data-bs-theme="dark"] .strain-ruderalis-text { + color: #fbbf24; +} /* Strain info list */ .strain-info-list li { diff --git a/web/static/js/add-strain-modal.js b/web/static/js/add-strain-modal.js index b793a70..239441a 100644 --- a/web/static/js/add-strain-modal.js +++ b/web/static/js/add-strain-modal.js @@ -2,9 +2,17 @@ document.addEventListener("DOMContentLoaded", () => { const addStrainForm = document.getElementById("addStrainForm"); const breederSelect = document.getElementById("breederSelect"); const newBreederInput = document.getElementById("newBreederInput"); - const indicaSativaSlider = document.getElementById("indicaSativaSlider"); + const indicaInput = document.getElementById("indicaInput"); + const sativaInput = document.getElementById("sativaInput"); + const ruderalisInput = document.getElementById("ruderalisInput"); + const geneticsLower = document.getElementById("geneticsLower"); + const geneticsUpper = document.getElementById("geneticsUpper"); const indicaLabel = document.getElementById("indicaLabel"); const sativaLabel = document.getElementById("sativaLabel"); + const ruderalisLabel = document.getElementById("ruderalisLabel"); + const ratioFillIndica = document.getElementById("ratioFillIndica"); + const ratioFillSativa = document.getElementById("ratioFillSativa"); + const ratioFillRuderalis = document.getElementById("ratioFillRuderalis"); const cycleTime = document.getElementById("cycleTime"); const url = document.getElementById("url"); @@ -17,13 +25,51 @@ document.addEventListener("DOMContentLoaded", () => { } }); - // Update labels dynamically as the slider changes - indicaSativaSlider.addEventListener("input", (e) => { - const indica = e.target.value; - const sativa = 100 - indica; - indicaLabel.textContent = `Indica: ${indica}%`; - sativaLabel.textContent = `Sativa: ${sativa}%`; - }); + function updateRatio(activeThumb) { + if (!geneticsLower || !geneticsUpper) return; + + let lower = parseInt(geneticsLower.value, 10); + let upper = parseInt(geneticsUpper.value, 10); + lower = Number.isFinite(lower) ? Math.min(100, Math.max(0, lower)) : 0; + upper = Number.isFinite(upper) ? Math.min(100, Math.max(0, upper)) : 100; + + if (lower > upper) { + if (activeThumb === "lower") { + upper = lower; + } else { + lower = upper; + } + } + + const indica = lower; + const sativa = upper - lower; + const ruderalis = 100 - upper; + + geneticsLower.value = String(lower); + geneticsUpper.value = String(upper); + if (indicaInput) indicaInput.value = String(indica); + if (sativaInput) sativaInput.value = String(sativa); + if (ruderalisInput) ruderalisInput.value = String(ruderalis); + if (indicaLabel) indicaLabel.textContent = `Indica: ${indica}%`; + if (sativaLabel) sativaLabel.textContent = `Sativa: ${sativa}%`; + if (ruderalisLabel) ruderalisLabel.textContent = `Ruderalis: ${ruderalis}%`; + if (ratioFillIndica) ratioFillIndica.style.width = `${indica}%`; + if (ratioFillSativa) ratioFillSativa.style.width = `${sativa}%`; + if (ratioFillRuderalis) ratioFillRuderalis.style.width = `${ruderalis}%`; + + const ratioError = document.getElementById("ratioError"); + if (indica + sativa + ruderalis !== 100) { + if (ratioError) ratioError.classList.remove("d-none"); + return false; + } + if (ratioError) ratioError.classList.add("d-none"); + return true; + } + if (geneticsLower) geneticsLower.addEventListener("input", () => updateRatio("lower")); + if (geneticsUpper) geneticsUpper.addEventListener("input", () => updateRatio("upper")); + if (geneticsLower) geneticsLower.value = "50"; + if (geneticsUpper) geneticsUpper.value = "100"; + updateRatio("upper"); // If no breeders exist, show the new breeder input by default if (document.getElementById("breederSelect").length === 1) { @@ -38,8 +84,9 @@ document.addEventListener("DOMContentLoaded", () => { name: document.getElementById("strainName").value, breeder_id: breederSelect.value === "new" ? null : parseInt(breederSelect.value, 10), new_breeder: breederSelect.value === "new" ? document.getElementById("newBreederName").value : null, - indica: parseInt(indicaSativaSlider.value, 10), - sativa: 100 - parseInt(indicaSativaSlider.value, 10), + indica: parseInt(indicaInput.value, 10) || 0, + sativa: parseInt(sativaInput.value, 10) || 0, + ruderalis: parseInt(ruderalisInput.value, 10) || 0, autoflower: document.getElementById("autoflower").value === "true", seed_count: parseInt(document.getElementById("seedCount").value, 10), description: document.getElementById("strainDescription").value, @@ -67,12 +114,10 @@ document.addEventListener("DOMContentLoaded", () => { try { addStrainForm.reset(); - // Reset slider and labels - if (indicaSativaSlider) { - indicaSativaSlider.value = 50; - if (indicaLabel) indicaLabel.textContent = `Indica: 50%`; - if (sativaLabel) sativaLabel.textContent = `Sativa: 50%`; - } + // Reset ratio inputs + if (geneticsLower) geneticsLower.value = "50"; + if (geneticsUpper) geneticsUpper.value = "100"; + updateRatio("upper"); // Reset breeder select and new breeder input if (breederSelect) { diff --git a/web/static/js/edit-strain-modal.js b/web/static/js/edit-strain-modal.js index 97d269b..f3e8121 100644 --- a/web/static/js/edit-strain-modal.js +++ b/web/static/js/edit-strain-modal.js @@ -3,12 +3,60 @@ document.addEventListener("DOMContentLoaded", () => { const deleteStrainButton = document.getElementById("deleteStrainButton"); const editBreederSelect = document.getElementById("editBreederSelect"); const editNewBreederInput = document.getElementById("editNewBreederInput"); - const editIndicaSativaSlider = document.getElementById("editIndicaSativaSlider"); + const editIndicaInput = document.getElementById("editIndicaInput"); + const editSativaInput = document.getElementById("editSativaInput"); + const editRuderalisInput = document.getElementById("editRuderalisInput"); + const editGeneticsLower = document.getElementById("editGeneticsLower"); + const editGeneticsUpper = document.getElementById("editGeneticsUpper"); const editIndicaLabel = document.getElementById("editIndicaLabel"); const editSativaLabel = document.getElementById("editSativaLabel"); + const editRuderalisLabel = document.getElementById("editRuderalisLabel"); + const editRatioFillIndica = document.getElementById("editRatioFillIndica"); + const editRatioFillSativa = document.getElementById("editRatioFillSativa"); + const editRatioFillRuderalis = document.getElementById("editRatioFillRuderalis"); const editCycleTime = document.getElementById("editCycleTime"); const editUrl = document.getElementById("editUrl"); + function updateEditRatio(activeThumb) { + if (!editGeneticsLower || !editGeneticsUpper) return; + + let lower = parseInt(editGeneticsLower.value, 10); + let upper = parseInt(editGeneticsUpper.value, 10); + lower = Number.isFinite(lower) ? Math.min(100, Math.max(0, lower)) : 0; + upper = Number.isFinite(upper) ? Math.min(100, Math.max(0, upper)) : 100; + + if (lower > upper) { + if (activeThumb === "lower") { + upper = lower; + } else { + lower = upper; + } + } + + const indica = lower; + const sativa = upper - lower; + const ruderalis = 100 - upper; + + editGeneticsLower.value = String(lower); + editGeneticsUpper.value = String(upper); + if (editIndicaInput) editIndicaInput.value = String(indica); + if (editSativaInput) editSativaInput.value = String(sativa); + if (editRuderalisInput) editRuderalisInput.value = String(ruderalis); + if (editIndicaLabel) editIndicaLabel.textContent = `Indica: ${indica}%`; + if (editSativaLabel) editSativaLabel.textContent = `Sativa: ${sativa}%`; + if (editRuderalisLabel) editRuderalisLabel.textContent = `Ruderalis: ${ruderalis}%`; + if (editRatioFillIndica) editRatioFillIndica.style.width = `${indica}%`; + if (editRatioFillSativa) editRatioFillSativa.style.width = `${sativa}%`; + if (editRatioFillRuderalis) editRatioFillRuderalis.style.width = `${ruderalis}%`; + + const ratioError = document.getElementById("editRatioError"); + if (indica + sativa + ruderalis !== 100) { + if (ratioError) ratioError.classList.remove("d-none"); + } else { + if (ratioError) ratioError.classList.add("d-none"); + } + } + // Helper to populate modal fields from a strain object function populateFields(strainData) { // strainData may use snake_case keys from API or keys used in list rendering @@ -17,6 +65,7 @@ document.addEventListener("DOMContentLoaded", () => { const breeder_id = strainData.breeder_id || strainData.breederId || strainData.BreederID || strainData.Breeder || null; const new_breeder = strainData.new_breeder || strainData.NewBreeder || ""; const indica = (typeof strainData.indica !== 'undefined') ? strainData.indica : (strainData.Indica || 50); + const ruderalis = (typeof strainData.ruderalis !== 'undefined') ? strainData.ruderalis : (strainData.Ruderalis || 0); const autoflower = (typeof strainData.autoflower !== 'undefined') ? strainData.autoflower : (strainData.Autoflower || false); const seed_count = strainData.seed_count || strainData.SeedCount || 0; const description = strainData.description || strainData.Description || ""; @@ -47,11 +96,12 @@ document.addEventListener("DOMContentLoaded", () => { } } - if (editIndicaSativaSlider) { - editIndicaSativaSlider.value = indica; - if (editIndicaLabel) editIndicaLabel.textContent = `Indica: ${indica}%`; - if (editSativaLabel) editSativaLabel.textContent = `Sativa: ${100 - indica}%`; - } + if (editIndicaInput) editIndicaInput.value = indica; + if (editSativaInput) editSativaInput.value = 100 - indica - ruderalis; + if (editRuderalisInput) editRuderalisInput.value = ruderalis; + if (editGeneticsLower) editGeneticsLower.value = String(indica); + if (editGeneticsUpper) editGeneticsUpper.value = String(100 - ruderalis); + updateEditRatio("upper"); if (document.getElementById("editAutoflower")) { // Support boolean true/false or 1/0 or string @@ -75,13 +125,11 @@ document.addEventListener("DOMContentLoaded", () => { } }); - // Update Indica/Sativa labels dynamically - editIndicaSativaSlider.addEventListener("input", () => { - const indica = editIndicaSativaSlider.value; - const sativa = 100 - indica; - editIndicaLabel.textContent = `Indica: ${indica}%`; - editSativaLabel.textContent = `Sativa: ${sativa}%`; - }); + if (editGeneticsLower) editGeneticsLower.addEventListener("input", () => updateEditRatio("lower")); + if (editGeneticsUpper) editGeneticsUpper.addEventListener("input", () => updateEditRatio("upper")); + if (editGeneticsLower) editGeneticsLower.value = editGeneticsLower.value || "50"; + if (editGeneticsUpper) editGeneticsUpper.value = editGeneticsUpper.value || "100"; + updateEditRatio("upper"); // Populate modal when it's shown (supports triggers from strain list & details) const modalEl = document.getElementById("editStrainModal"); @@ -130,8 +178,9 @@ document.addEventListener("DOMContentLoaded", () => { name: document.getElementById("editStrainName").value, breeder_id: editBreederSelect.value === "new" ? null : parseInt(editBreederSelect.value, 10), new_breeder: editBreederSelect.value === "new" ? document.getElementById("editNewBreederName").value : null, - indica: parseInt(editIndicaSativaSlider.value, 10), - sativa: 100 - parseInt(editIndicaSativaSlider.value, 10), + indica: parseInt(editIndicaInput.value, 10) || 0, + sativa: parseInt(editSativaInput.value, 10) || 0, + ruderalis: parseInt(editRuderalisInput.value, 10) || 0, autoflower: document.getElementById("editAutoflower").value === "true", seed_count: parseInt(document.getElementById("editSeedCount").value, 10), description: document.getElementById("editStrainDescription").value, diff --git a/web/static/js/strain-add.js b/web/static/js/strain-add.js index ed58d80..2d6035e 100644 --- a/web/static/js/strain-add.js +++ b/web/static/js/strain-add.js @@ -2,10 +2,17 @@ document.addEventListener("DOMContentLoaded", () => { const form = document.getElementById("addStrainForm"); const editBreederSelect = document.getElementById("editBreederSelect"); const editNewBreederInput = document.getElementById("editNewBreederInput"); - const editIndicaSativaSlider = document.getElementById("editIndicaSativaSlider"); + const editIndicaInput = document.getElementById("editIndicaInput"); + const editSativaInput = document.getElementById("editSativaInput"); + const editRuderalisInput = document.getElementById("editRuderalisInput"); + const editGeneticsLower = document.getElementById("editGeneticsLower"); + const editGeneticsUpper = document.getElementById("editGeneticsUpper"); const editIndicaLabel = document.getElementById("editIndicaLabel"); const editSativaLabel = document.getElementById("editSativaLabel"); - const editRatioFill = document.getElementById("editRatioFill"); + const editRuderalisLabel = document.getElementById("editRuderalisLabel"); + const editRatioFillIndica = document.getElementById("editRatioFillIndica"); + const editRatioFillSativa = document.getElementById("editRatioFillSativa"); + const editRatioFillRuderalis = document.getElementById("editRatioFillRuderalis"); const descriptionTextarea = document.getElementById("editStrainDescription"); const markdownPreview = document.getElementById("markdownPreview"); @@ -20,17 +27,72 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // --- Indica/Sativa slider with live ratio bar preview --- - if (editIndicaSativaSlider) { - editIndicaSativaSlider.addEventListener("input", () => { - const indica = editIndicaSativaSlider.value; - const sativa = 100 - indica; - if (editIndicaLabel) editIndicaLabel.textContent = `Indica: ${indica}%`; - if (editSativaLabel) editSativaLabel.textContent = `Sativa: ${sativa}%`; - if (editRatioFill) editRatioFill.style.width = `${indica}%`; - }); + // --- Indica/Sativa/Ruderalis dual-thumb slider --- + function updateRatioPreview(activeThumb) { + if (!editGeneticsLower || !editGeneticsUpper) return; + + let lower = parseInt(editGeneticsLower.value, 10); + let upper = parseInt(editGeneticsUpper.value, 10); + lower = Number.isFinite(lower) ? Math.min(100, Math.max(0, lower)) : 0; + upper = Number.isFinite(upper) ? Math.min(100, Math.max(0, upper)) : 100; + + if (lower > upper) { + if (activeThumb === "lower") { + upper = lower; + } else { + lower = upper; + } + } + + const indica = lower; + const sativa = upper - lower; + const ruderalis = 100 - upper; + + editGeneticsLower.value = String(lower); + editGeneticsUpper.value = String(upper); + if (editIndicaInput) editIndicaInput.value = String(indica); + if (editSativaInput) editSativaInput.value = String(sativa); + if (editRuderalisInput) editRuderalisInput.value = String(ruderalis); + + if (editIndicaLabel) editIndicaLabel.textContent = `Indica: ${indica}%`; + if (editSativaLabel) editSativaLabel.textContent = `Sativa: ${sativa}%`; + if (editRuderalisLabel) editRuderalisLabel.textContent = `Ruderalis: ${ruderalis}%`; + if (editRatioFillIndica) editRatioFillIndica.style.width = `${indica}%`; + if (editRatioFillSativa) editRatioFillSativa.style.width = `${sativa}%`; + if (editRatioFillRuderalis) editRatioFillRuderalis.style.width = `${ruderalis}%`; + const ratioError = document.getElementById("editRatioError"); + if (indica + sativa + ruderalis !== 100) { + if (ratioError) ratioError.classList.remove("d-none"); + } else { + if (ratioError) ratioError.classList.add("d-none"); + } } + function initRatioSlider() { + if (!editGeneticsLower || !editGeneticsUpper) return; + const indica = parseInt(editIndicaInput?.value, 10); + const sativa = parseInt(editSativaInput?.value, 10); + const ruderalis = parseInt(editRuderalisInput?.value, 10); + + if ( + Number.isFinite(indica) && Number.isFinite(sativa) && Number.isFinite(ruderalis) && + indica >= 0 && sativa >= 0 && ruderalis >= 0 && + indica + sativa + ruderalis === 100 + ) { + editGeneticsLower.value = String(indica); + editGeneticsUpper.value = String(indica + sativa); + } else { + editGeneticsLower.value = "50"; + editGeneticsUpper.value = "100"; + } + + updateRatioPreview("upper"); + } + + if (editGeneticsLower) editGeneticsLower.addEventListener("input", () => updateRatioPreview("lower")); + if (editGeneticsUpper) editGeneticsUpper.addEventListener("input", () => updateRatioPreview("upper")); + initRatioSlider(); + // --- Markdown live preview (debounced) --- let previewTimeout = null; if (descriptionTextarea && markdownPreview) { @@ -80,8 +142,9 @@ document.addEventListener("DOMContentLoaded", () => { name: document.getElementById("editStrainName").value, breeder_id: editBreederSelect.value === "new" ? null : parseInt(editBreederSelect.value, 10), new_breeder: editBreederSelect.value === "new" ? document.getElementById("editNewBreederName").value : null, - indica: parseInt(editIndicaSativaSlider.value, 10), - sativa: 100 - parseInt(editIndicaSativaSlider.value, 10), + indica: parseInt(editIndicaInput.value, 10) || 0, + sativa: parseInt(editSativaInput.value, 10) || 0, + ruderalis: parseInt(editRuderalisInput.value, 10) || 0, autoflower: document.getElementById("editAutoflower").value === "true", seed_count: parseInt(document.getElementById("editSeedCount").value, 10), description: descriptionTextarea.value, @@ -95,8 +158,19 @@ document.addEventListener("DOMContentLoaded", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) - .then(response => { - if (!response.ok) throw new Error("Failed to add strain"); + .then(async response => { + if (!response.ok) { + let errorMessage = "Failed to add strain"; + try { + const errorBody = await response.json(); + if (errorBody && typeof errorBody.error === "string" && errorBody.error.trim()) { + errorMessage = errorBody.error; + } + } catch (_err) { + // Ignore JSON parsing errors and keep default message. + } + throw new Error(errorMessage); + } return response.json(); }) .then(data => { diff --git a/web/static/js/strain-edit.js b/web/static/js/strain-edit.js index caa6299..3f20e3c 100644 --- a/web/static/js/strain-edit.js +++ b/web/static/js/strain-edit.js @@ -3,10 +3,17 @@ document.addEventListener("DOMContentLoaded", () => { const deleteStrainButton = document.getElementById("deleteStrainButton"); const editBreederSelect = document.getElementById("editBreederSelect"); const editNewBreederInput = document.getElementById("editNewBreederInput"); - const editIndicaSativaSlider = document.getElementById("editIndicaSativaSlider"); + const editIndicaInput = document.getElementById("editIndicaInput"); + const editSativaInput = document.getElementById("editSativaInput"); + const editRuderalisInput = document.getElementById("editRuderalisInput"); + const editGeneticsLower = document.getElementById("editGeneticsLower"); + const editGeneticsUpper = document.getElementById("editGeneticsUpper"); const editIndicaLabel = document.getElementById("editIndicaLabel"); const editSativaLabel = document.getElementById("editSativaLabel"); - const editRatioFill = document.getElementById("editRatioFill"); + const editRuderalisLabel = document.getElementById("editRuderalisLabel"); + const editRatioFillIndica = document.getElementById("editRatioFillIndica"); + const editRatioFillSativa = document.getElementById("editRatioFillSativa"); + const editRatioFillRuderalis = document.getElementById("editRatioFillRuderalis"); const descriptionTextarea = document.getElementById("editStrainDescription"); const markdownPreview = document.getElementById("markdownPreview"); @@ -21,17 +28,72 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // --- Indica/Sativa slider with live ratio bar preview --- - if (editIndicaSativaSlider) { - editIndicaSativaSlider.addEventListener("input", () => { - const indica = editIndicaSativaSlider.value; - const sativa = 100 - indica; - if (editIndicaLabel) editIndicaLabel.textContent = `Indica: ${indica}%`; - if (editSativaLabel) editSativaLabel.textContent = `Sativa: ${sativa}%`; - if (editRatioFill) editRatioFill.style.width = `${indica}%`; - }); + // --- Indica/Sativa/Ruderalis dual-thumb slider --- + function updateRatioPreview(activeThumb) { + if (!editGeneticsLower || !editGeneticsUpper) return; + + let lower = parseInt(editGeneticsLower.value, 10); + let upper = parseInt(editGeneticsUpper.value, 10); + lower = Number.isFinite(lower) ? Math.min(100, Math.max(0, lower)) : 0; + upper = Number.isFinite(upper) ? Math.min(100, Math.max(0, upper)) : 100; + + if (lower > upper) { + if (activeThumb === "lower") { + upper = lower; + } else { + lower = upper; + } + } + + const indica = lower; + const sativa = upper - lower; + const ruderalis = 100 - upper; + + editGeneticsLower.value = String(lower); + editGeneticsUpper.value = String(upper); + if (editIndicaInput) editIndicaInput.value = String(indica); + if (editSativaInput) editSativaInput.value = String(sativa); + if (editRuderalisInput) editRuderalisInput.value = String(ruderalis); + + if (editIndicaLabel) editIndicaLabel.textContent = `Indica: ${indica}%`; + if (editSativaLabel) editSativaLabel.textContent = `Sativa: ${sativa}%`; + if (editRuderalisLabel) editRuderalisLabel.textContent = `Ruderalis: ${ruderalis}%`; + if (editRatioFillIndica) editRatioFillIndica.style.width = `${indica}%`; + if (editRatioFillSativa) editRatioFillSativa.style.width = `${sativa}%`; + if (editRatioFillRuderalis) editRatioFillRuderalis.style.width = `${ruderalis}%`; + const ratioError = document.getElementById("editRatioError"); + if (indica + sativa + ruderalis !== 100) { + if (ratioError) ratioError.classList.remove("d-none"); + } else { + if (ratioError) ratioError.classList.add("d-none"); + } } + function initRatioSlider() { + if (!editGeneticsLower || !editGeneticsUpper) return; + const indica = parseInt(editIndicaInput?.value, 10); + const sativa = parseInt(editSativaInput?.value, 10); + const ruderalis = parseInt(editRuderalisInput?.value, 10); + + if ( + Number.isFinite(indica) && Number.isFinite(sativa) && Number.isFinite(ruderalis) && + indica >= 0 && sativa >= 0 && ruderalis >= 0 && + indica + sativa + ruderalis === 100 + ) { + editGeneticsLower.value = String(indica); + editGeneticsUpper.value = String(indica + sativa); + } else { + editGeneticsLower.value = "50"; + editGeneticsUpper.value = "100"; + } + + updateRatioPreview("upper"); + } + + if (editGeneticsLower) editGeneticsLower.addEventListener("input", () => updateRatioPreview("lower")); + if (editGeneticsUpper) editGeneticsUpper.addEventListener("input", () => updateRatioPreview("upper")); + initRatioSlider(); + // --- Markdown live preview (debounced) --- let previewTimeout = null; if (descriptionTextarea && markdownPreview) { @@ -107,8 +169,9 @@ document.addEventListener("DOMContentLoaded", () => { name: document.getElementById("editStrainName").value, breeder_id: editBreederSelect.value === "new" ? null : parseInt(editBreederSelect.value, 10), new_breeder: editBreederSelect.value === "new" ? document.getElementById("editNewBreederName").value : null, - indica: parseInt(editIndicaSativaSlider.value, 10), - sativa: 100 - parseInt(editIndicaSativaSlider.value, 10), + indica: parseInt(editIndicaInput.value, 10) || 0, + sativa: parseInt(editSativaInput.value, 10) || 0, + ruderalis: parseInt(editRuderalisInput.value, 10) || 0, autoflower: document.getElementById("editAutoflower").value === "true", seed_count: parseInt(document.getElementById("editSeedCount").value, 10), description: descriptionTextarea.value, @@ -122,8 +185,19 @@ document.addEventListener("DOMContentLoaded", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) - .then(response => { - if (!response.ok) throw new Error("Failed to update strain"); + .then(async response => { + if (!response.ok) { + let errorMessage = "Failed to update strain"; + try { + const errorBody = await response.json(); + if (errorBody && typeof errorBody.error === "string" && errorBody.error.trim()) { + errorMessage = errorBody.error; + } + } catch (_err) { + // Ignore JSON parsing errors and keep default message. + } + throw new Error(errorMessage); + } // Save lineage if the editor is present if (typeof window.saveLineage === "function") { return window.saveLineage().then(() => strainId); @@ -139,7 +213,7 @@ document.addEventListener("DOMContentLoaded", () => { .catch(error => { console.error("Error updating strain:", error); if (typeof uiMessages !== 'undefined') { - uiMessages.showToast(uiMessages.t('update_error') || 'Update failed', 'danger'); + uiMessages.showToast(uiMessages.t('strain_update_fail') || 'Update failed', 'danger'); } }); }); diff --git a/web/templates/modals/add-strain-modal.html b/web/templates/modals/add-strain-modal.html index 241177b..f0ddbd1 100644 --- a/web/templates/modals/add-strain-modal.html +++ b/web/templates/modals/add-strain-modal.html @@ -38,14 +38,29 @@