From 15bf3c3a185464c0fcba1bf71fc26ca5a9498bf9 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Thu, 30 Apr 2026 19:50:27 +0200 Subject: [PATCH] feat: edit and delete settings history rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - SettingsStore: Add GetByID, Update, Delete, Count methods - SettingsService: Add UpdateSettings (validates same rules as Upsert), DeleteSettings (guards against deleting the last row → 409) - New sentinels: ErrSettingsNotFound, ErrLastSettingsRow - Handler: PUT /api/settings/history/{id} → 200 updated row DELETE /api/settings/history/{id} → 204 / 404 / 409 Frontend: - API client: settings.update(id, body) and settings.delete(id) - Settings page: history table gains edit (pencil) and delete (×) buttons - Inline edit form expands in place within the table row - Delete button disabled and hint shown when only one row remains - maskLabel() helper shows workday names instead of raw bitmask - After save/delete: full reload to reflect changes in 'current' section --- internal/handler/settings_handler.go | 63 ++++++++++++ internal/service/settings_service.go | 67 ++++++++++++ internal/store/settings_store.go | 31 ++++++ web/src/lib/api/client.ts | 9 +- web/src/routes/settings/+page.svelte | 147 +++++++++++++++++++++++++-- 5 files changed, 309 insertions(+), 8 deletions(-) diff --git a/internal/handler/settings_handler.go b/internal/handler/settings_handler.go index 7cea519..d6eb021 100644 --- a/internal/handler/settings_handler.go +++ b/internal/handler/settings_handler.go @@ -1,8 +1,10 @@ package handler import ( + "database/sql" "errors" "net/http" + "strconv" "github.com/go-chi/chi/v5" "github.com/wotra/wotra/internal/service" @@ -21,6 +23,8 @@ func (h *SettingsHandler) Routes(r chi.Router) { r.Get("/settings", h.Current) r.Put("/settings", h.Upsert) r.Get("/settings/history", h.History) + r.Put("/settings/history/{id}", h.UpdateHistoryRow) + r.Delete("/settings/history/{id}", h.DeleteHistoryRow) } // Current GET /api/settings @@ -76,3 +80,62 @@ func (h *SettingsHandler) History(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, history) } + +// UpdateHistoryRow PUT /api/settings/history/{id} +func (h *SettingsHandler) UpdateHistoryRow(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var body struct { + EffectiveFrom string `json:"effective_from"` + HoursPerWeek float64 `json:"hours_per_week"` + WorkdaysMask int `json:"workdays_mask"` + Timezone string `json:"timezone"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + set, err := h.svc.UpdateSettings(r.Context(), id, service.UpdateSettingsInput{ + EffectiveFrom: body.EffectiveFrom, + HoursPerWeek: body.HoursPerWeek, + WorkdaysMask: body.WorkdaysMask, + Timezone: body.Timezone, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrSettingsNotFound), errors.Is(err, sql.ErrNoRows): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask): + writeError(w, http.StatusUnprocessableEntity, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, set) +} + +// DeleteHistoryRow DELETE /api/settings/history/{id} +func (h *SettingsHandler) DeleteHistoryRow(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.svc.DeleteSettings(r.Context(), id); err != nil { + switch { + case errors.Is(err, service.ErrSettingsNotFound): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, service.ErrLastSettingsRow): + writeError(w, http.StatusConflict, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + diff --git a/internal/service/settings_service.go b/internal/service/settings_service.go index 8551953..e7360f9 100644 --- a/internal/service/settings_service.go +++ b/internal/service/settings_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "database/sql" "errors" "fmt" "time" @@ -14,6 +15,8 @@ var ( ErrNoSettings = errors.New("no settings found") ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127") ErrInvalidHours = errors.New("hours_per_week must be > 0") + ErrSettingsNotFound = errors.New("settings row not found") + ErrLastSettingsRow = errors.New("cannot delete the only settings row") ) // SettingsService manages settings with effective-from history. @@ -87,3 +90,67 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput) } return set, nil } + +// UpdateSettingsInput is the payload for editing an existing settings row. +type UpdateSettingsInput struct { + EffectiveFrom string + HoursPerWeek float64 + WorkdaysMask int + Timezone string +} + +// UpdateSettings edits an existing settings row in-place. +func (s *SettingsService) UpdateSettings(ctx context.Context, id int64, input UpdateSettingsInput) (*domain.Settings, error) { + if input.HoursPerWeek <= 0 { + return nil, ErrInvalidHours + } + if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 { + return nil, ErrInvalidWorkdaysMask + } + if input.Timezone == "" { + input.Timezone = "UTC" + } + if _, err := time.LoadLocation(input.Timezone); err != nil { + return nil, fmt.Errorf("invalid timezone: %w", err) + } + if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil { + return nil, fmt.Errorf("invalid effective_from: %w", err) + } + + set, err := s.store.GetByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSettingsNotFound + } + return nil, err + } + + set.EffectiveFrom = input.EffectiveFrom + set.HoursPerWeek = input.HoursPerWeek + set.WorkdaysMask = input.WorkdaysMask + set.Timezone = input.Timezone + + if err := s.store.Update(ctx, set); err != nil { + return nil, err + } + return set, nil +} + +// DeleteSettings removes a settings row. Refuses if it is the only row. +func (s *SettingsService) DeleteSettings(ctx context.Context, id int64) error { + count, err := s.store.Count(ctx) + if err != nil { + return err + } + if count <= 1 { + return ErrLastSettingsRow + } + // Confirm row exists before deleting. + if _, err := s.store.GetByID(ctx, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrSettingsNotFound + } + return err + } + return s.store.Delete(ctx, id) +} diff --git a/internal/store/settings_store.go b/internal/store/settings_store.go index 255325f..6d6672c 100644 --- a/internal/store/settings_store.go +++ b/internal/store/settings_store.go @@ -71,6 +71,37 @@ func (s *SettingsStore) Insert(ctx context.Context, set *domain.Settings) error return nil } +// Update overwrites an existing settings row by ID. +func (s *SettingsStore) Update(ctx context.Context, set *domain.Settings) error { + _, err := s.db.ExecContext(ctx, + `UPDATE settings_history + SET effective_from=?, hours_per_week=?, workdays_mask=?, timezone=? + WHERE id=?`, + set.EffectiveFrom, set.HoursPerWeek, set.WorkdaysMask, set.Timezone, set.ID) + return err +} + +// Delete removes a settings row by ID. +func (s *SettingsStore) Delete(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM settings_history WHERE id=?`, id) + return err +} + +// Count returns the total number of settings rows. +func (s *SettingsStore) Count(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM settings_history`).Scan(&n) + return n, err +} + +// GetByID returns a single settings row by ID. +func (s *SettingsStore) GetByID(ctx context.Context, id int64) (*domain.Settings, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at + FROM settings_history WHERE id=?`, id) + return scanSettings(row) +} + func scanSettings(row *sql.Row) (*domain.Settings, error) { var s domain.Settings err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 7e9339b..d00d194 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -137,7 +137,14 @@ export const settings = { hours_per_week: number; workdays_mask: number; timezone: string; - }) => request('PUT', '/settings', body) + }) => request('PUT', '/settings', body), + update: (id: number, body: { + effective_from: string; + hours_per_week: number; + workdays_mask: number; + timezone: string; + }) => request('PUT', `/settings/history/${id}`, body), + delete: (id: number) => request('DELETE', `/settings/history/${id}`) }; // ─── Health ────────────────────────────────────────────────────────────────── diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 6030580..1d45824 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -18,6 +18,14 @@ let formWorkdaysMask = $state(31); let formTimezone = $state('UTC'); + // Inline edit state for history rows + let editingId = $state(null); + let editEffectiveFrom = $state(''); + let editHoursPerWeek = $state(0); + let editWorkdaysMask = $state(31); + let editTimezone = $state(''); + let editError = $state(''); + // Workday checkboxes: Mon=bit1..Sun=bit64 const DAYS = [ { label: 'Mon', bit: 1 }, @@ -33,6 +41,14 @@ formWorkdaysMask ^= bit; } + function toggleEditDay(bit: number) { + editWorkdaysMask ^= bit; + } + + function maskLabel(mask: number): string { + return DAYS.filter((d) => mask & d.bit).map((d) => d.label).join(', '); + } + async function load() { if (!hasToken()) return; error = ''; @@ -70,9 +86,50 @@ workdays_mask: formWorkdaysMask, timezone: formTimezone }); - current = s; - history = [s, ...history]; saved = 'Settings saved!'; + await load(); + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } + } + + // ── Inline edit ────────────────────────────────────────────────────────────── + + function startEdit(s: Settings) { + editingId = s.id; + editEffectiveFrom = s.effective_from; + editHoursPerWeek = s.hours_per_week; + editWorkdaysMask = s.workdays_mask; + editTimezone = s.timezone; + editError = ''; + } + + function cancelEdit() { + editingId = null; + editError = ''; + } + + async function saveEdit(id: number) { + editError = ''; + try { + await settings.update(id, { + effective_from: editEffectiveFrom, + hours_per_week: editHoursPerWeek, + workdays_mask: editWorkdaysMask, + timezone: editTimezone + }); + editingId = null; + await load(); + } catch (e) { + editError = e instanceof ApiError ? e.message : String(e); + } + } + + async function handleDelete(id: number) { + error = ''; + try { + await settings.delete(id); + await load(); } catch (e) { error = e instanceof ApiError ? e.message : String(e); } @@ -103,7 +160,7 @@
-

Working Hours

+

Add / change working hours