From 21d0747ff49584fd28ec9beeafb94160cbb24fc3 Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Thu, 18 Jun 2026 17:06:42 +0200 Subject: [PATCH 1/2] Add subjective check-in calendar SLA --- .../2026-06-18-checkin-calendar-sla.html | 213 +++++++++++++++++ internal/storage/subjective_checkin.go | 217 +++++++++++++++++- internal/storage/subjective_checkin_test.go | 65 ++++++ internal/ui/admin_checkin_coverage_test.go | 89 +++++++ internal/ui/handler.go | 16 +- internal/ui/i18n_en.go | 9 +- internal/ui/i18n_ru.go | 9 +- internal/ui/i18n_sr.go | 9 +- internal/ui/style.go | 16 ++ internal/ui/templates/pages/admin.html | 117 +++++++--- 10 files changed, 711 insertions(+), 49 deletions(-) create mode 100644 docs/ai-plans/2026-06-18-checkin-calendar-sla.html diff --git a/docs/ai-plans/2026-06-18-checkin-calendar-sla.html b/docs/ai-plans/2026-06-18-checkin-calendar-sla.html new file mode 100644 index 0000000..fd7df56 --- /dev/null +++ b/docs/ai-plans/2026-06-18-checkin-calendar-sla.html @@ -0,0 +1,213 @@ + + + + + Implementation Plan: Subjective Check-in Calendar SLA + + + + +
+

Implementation Plan: Subjective Check-in Calendar SLA

+ Approval required before implementation +

Created 2026-06-18 for the overdue Todoist follow-up: Subjective check-in — calendar SLA after explicit enabled-since date.

+ +
+

Task Summary

+

+ Add an explicit per-tenant date from which subjective Telegram check-in prompts are expected, + then expose a calendar-day SLA view that marks missing expected prompt days as real failures. + Keep the current latest-prompts view because it remains useful for operator history and response latency. +

+
+ +
+

Current Behavior

+
    +
  • internal/storage/subjective_checkin.go owns subjective_checkins, with one row per (date, source).
  • +
  • GetCheckinCoverage(today, source, days) deliberately returns the latest N actual rows only. It does not synthesize missing calendar dates.
  • +
  • The code comment says this was intentional during rollout, because pre-rollout calendar gaps would create false failures.
  • +
  • CheckinStatusMissing already exists, and the admin UI already has CSS/status labels for missing, but no storage path emits missing rows today.
  • +
  • /api/admin/checkin-coverage?days=14 is admin-only, resolves the active tenant, computes tenant-local today, and returns the current read model.
  • +
  • internal/ui/templates/pages/admin.html renders one Subjective check-in coverage table from that response.
  • +
  • The feature-enabled check for the morning gate is currently webhook-secret based in cmd/server/main.go::morningCheckinEnabled; it is not persisted as a calendar SLA start date.
  • +
+
+ +
+

Desired Behavior

+
+
+

Explicit Start

+

Store a tenant-local YYYY-MM-DD setting such as subjective_checkin_enabled_since. Empty means calendar SLA is not active yet.

+
+
+

Calendar SLA

+

For dates from max(enabled_since, today-days+1) through today, synthesize one row per day. Missing DB rows become missing.

+
+
+

History Preserved

+

Continue returning/rendering latest actual prompt rows separately so response latency and late answers remain easy to inspect.

+
+
+
+ +
+

Assumptions and Unknowns

+
    +
  • Use the existing per-tenant settings table instead of a schema migration. The table already supports arbitrary key/value tenant settings.
  • +
  • The enabled-since date should be set by an admin/operator, not inferred from the first prompt row, because the requirement says explicit enabled-since date.
  • +
  • Calendar missing means no row exists for (date, telegram). A row with expired is not missing; it means the prompt was sent and not answered before cap.
  • +
  • Open question: should the implementation provide a one-click "set to first prompt date" helper, or only a manual date input?
  • +
  • Open question: should today count as missing before the morning check-in window has elapsed? The conservative default is to include today only after the tenant-local morning cap has passed, or mark it as pending if the day is still in progress.
  • +
+
+ +
+

Files Likely to Change

+
    +
  • internal/storage/subjective_checkin.go - add enabled-since helpers and a calendar SLA read model that overlays actual rows on expected dates.
  • +
  • internal/storage/subjective_checkin_test.go - unit tests for calendar synthesis, missing rows, lower bound at enabled-since, and current latest-row behavior.
  • +
  • internal/ui/handler.go - extend adminCheckinCoverage or add a small companion admin endpoint to save/read subjective_checkin_enabled_since.
  • +
  • internal/ui/admin_checkin_coverage_test.go - API JSON shape tests for calendar rows and invalid date handling.
  • +
  • internal/ui/templates/pages/admin.html - add date input/save control and render calendar SLA plus latest prompt history.
  • +
  • internal/ui/i18n_en.go, internal/ui/i18n_ru.go, internal/ui/i18n_sr.go - labels for enabled-since, SLA mode, history table, pending/missing explanation if needed.
  • +
  • internal/ui/style.go - small layout adjustments if the two-table admin section needs clearer spacing.
  • +
+
+ +
+

Proposed Implementation Steps

+
    +
  1. Add constants and validation for subjective_checkin_enabled_since in storage. Accept empty or valid YYYY-MM-DD.
  2. +
  3. Add GetCheckinEnabledSince and SaveCheckinEnabledSince wrappers over settings.
  4. +
  5. Keep GetCheckinCoverage as the latest-actual history path, or rename internally to make that explicit while preserving the API shape.
  6. +
  7. Add a calendar builder that receives today, enabledSince, days, and actual rows, then returns expected rows newest-first with synthesized missing rows.
  8. +
  9. Extend the admin response with enabled_since, sla_active, calendar_rows, and calendar_summary, while keeping existing rows and summary for history.
  10. +
  11. Add admin POST handling for the enabled-since date. Keep it tenant-scoped through the existing resolveAdminTenantScope path.
  12. +
  13. Update the admin UI to show a date control, a clear inactive state when no date is set, a calendar SLA table, and the existing latest prompt history table.
  14. +
  15. Update this plan after implementation with actual changes, checks run, limitations, and follow-up recommendations.
  16. +
+
+ +
+

Data, API, Auth, and UX Impact

+
    +
  • Data: no new table required if the enabled-since value lives in settings. No destructive migration.
  • +
  • API: admin-only check-in coverage response gains fields. Existing fields should remain stable to reduce template and test churn.
  • +
  • Auth: no change. Reads and writes stay behind adminGuard and tenant scope resolution.
  • +
  • UX: the admin page will distinguish "SLA not active" from "SLA active and missing prompt rows". This avoids false alarms before explicit enablement.
  • +
  • Deployment: safe deploy before setting the date. Calendar missing rows only appear after the admin setting is saved.
  • +
+
+ +
+

Test Plan

+
    +
  • go test ./internal/storage -run "Test.*Checkin|Test.*CheckinCoverage" -count=1
  • +
  • go test ./internal/ui -run TestAdminCheckinCoverage -count=1
  • +
  • If DB-backed behavior is touched beyond pure builders, run the targeted DB-enabled UI/storage tests using the repo's usual DB environment.
  • +
  • Run gofmt -w on changed Go files.
  • +
  • Manual JSON check: call /api/admin/checkin-coverage?days=14 for a tenant with and without subjective_checkin_enabled_since.
  • +
+
+ +
+

Manual QA Checklist

+
    +
  • Admin page shows check-in coverage without JavaScript errors when enabled-since is empty.
  • +
  • Saving an enabled-since date persists for the active tenant only.
  • +
  • Calendar SLA starts no earlier than enabled-since and no earlier than the selected day window.
  • +
  • Dates with actual answered, late_answered, expired, or prompted rows keep those statuses.
  • +
  • Dates with no row after enabled-since render as missing.
  • +
  • Latest prompt history still shows actual rows only and still reports average response latency from answered rows.
  • +
  • Multi-tenant admin tab switching reloads the correct tenant's SLA date and rows.
  • +
+
+ +
+

Risks and Edge Cases

+
    +
  • Counting today too early could create noisy false missing rows. The implementation should either exclude in-progress today until cap or label it pending.
  • +
  • Timezone drift matters because dates are tenant-local. Use the same tenantLocalToday path as the current endpoint.
  • +
  • Manual date mistakes can create many historical missing rows. Keep the setting visible and easy to correct.
  • +
  • Calendar rows and latest rows have different denominators. The UI labels must make that difference explicit.
  • +
  • Do not make missing rows affect health scoring, illness evidence, or report generation. This is admin observability only.
  • +
+
+ +
+

Rollback Plan

+
    +
  • Clear or delete settings.subjective_checkin_enabled_since to disable calendar SLA output without changing prompt delivery.
  • +
  • Revert the code changes if needed; no derived health data or raw health records are modified by this feature.
  • +
  • If the response extension causes UI trouble, keep the storage helper and temporarily render only the existing latest history table.
  • +
+
+ +
+

Open Questions

+
    +
  • Should the first production enabled-since date be set to the actual rollout date from PR 2, the first prompt row, or a manually chosen date?
  • +
  • Should in-progress today be excluded, shown as prompted when a prompt exists, or shown as a separate pending state when no prompt exists yet?
  • +
  • Do we want a Todoist task close/update after implementation, or should that remain manual?
  • +
+
+ +
+

Implemented Result

+
    +
  • Created GitHub issue #186: Add calendar-day SLA for subjective check-in prompts.
  • +
  • Added tenant-scoped setting subjective_checkin_enabled_since through the existing settings table.
  • +
  • Extended /api/admin/checkin-coverage: GET returns existing latest history plus new SLA fields; POST saves enabled_since for the active tenant and returns refreshed coverage.
  • +
  • Kept existing summary and rows as latest actual prompt history for compatibility.
  • +
  • Added sla_summary and sla_rows for calendar-day coverage after enabled-since.
  • +
  • Added pending for the current day when no row exists yet, so today is not prematurely counted as missing.
  • +
  • Updated Admin UI with an enabled-since date input, Calendar SLA table, and Latest prompt history table.
  • +
  • Added English, Russian, and Serbian labels plus minimal CSS for the new UI.
  • +
+
+ +
+

Checks Run

+
    +
  • gofmt -w internal\storage\subjective_checkin.go internal\storage\subjective_checkin_test.go internal\ui\handler.go internal\ui\admin_checkin_coverage_test.go internal\ui\i18n_en.go internal\ui\i18n_ru.go internal\ui\i18n_sr.go
  • +
  • go test ./internal/storage -run "Test.*Checkin|Test.*CheckinCoverage" -count=1 - passed.
  • +
  • go test ./internal/ui -run TestAdminCheckinCoverage -count=1 - passed.
  • +
+
+ +
+

Known Limitations

+
    +
  • The current day with no prompt row is shown as pending for the full day. This is deliberately conservative; a later refinement can switch it to missing after the tenant's morning cap passes.
  • +
  • Clearing enabled-since stores an empty settings value rather than deleting the row; existing settings helpers treat that as inactive.
  • +
  • No production setting was changed during implementation. Enabling SLA for a tenant is still an explicit admin action.
  • +
+
+ +
+

Approval Gate

+

+ Implementation must not start until the user explicitly approves this plan. + Suggested approval phrase: погнали. +

+
+
+ + diff --git a/internal/storage/subjective_checkin.go b/internal/storage/subjective_checkin.go index adeebba..50ed829 100644 --- a/internal/storage/subjective_checkin.go +++ b/internal/storage/subjective_checkin.go @@ -36,6 +36,8 @@ const ( // analytics can tell them apart per entry point. const CheckinSourceTelegram = "telegram" +const SettingSubjectiveCheckinEnabledSince = "subjective_checkin_enabled_since" + // subjectiveCheckinTableDDL returns the CREATE TABLE statement for // subjective_checkins. Exposed package-internal so the structural test // can assert column shape without a live DB. @@ -140,17 +142,21 @@ type CheckinRow struct { } // CheckinCoverage is the admin read model for the morning check-in -// coverage table. It includes the latest N subjective_checkins rows. -// It intentionally does not synthesize pre-rollout "missing" rows: -// during the first two weeks after launch, calendar-day coverage would -// make a perfectly healthy rollout look mostly broken. +// coverage table. Rows/Summary keep the latest actual subjective_checkins +// history. SLARows/SLASummary synthesize calendar-day rows only after an +// explicit enabled-since date is configured, avoiding false pre-rollout +// missing days. type CheckinCoverage struct { - From string `json:"from"` - To string `json:"to"` - Days int `json:"days"` - Source string `json:"source"` - Summary CheckinCoverageSummary `json:"summary"` - Rows []CheckinCoverageRow `json:"rows"` + From string `json:"from"` + To string `json:"to"` + Days int `json:"days"` + Source string `json:"source"` + EnabledSince string `json:"enabled_since,omitempty"` + SLAActive bool `json:"sla_active"` + SLASummary CheckinCoverageSummary `json:"sla_summary"` + SLARows []CheckinCoverageRow `json:"sla_rows"` + Summary CheckinCoverageSummary `json:"summary"` + Rows []CheckinCoverageRow `json:"rows"` } type CheckinCoverageSummary struct { @@ -160,6 +166,7 @@ type CheckinCoverageSummary struct { LateAnswered int `json:"late_answered"` Expired int `json:"expired"` Missing int `json:"missing"` + Pending int `json:"pending"` AnswerCounts map[string]int `json:"answer_counts"` AverageResponseSeconds *int64 `json:"average_response_seconds,omitempty"` AnsweredCoveragePercent int `json:"answered_coverage_percent"` @@ -178,6 +185,28 @@ type CheckinCoverageRow struct { } const CheckinStatusMissing = "missing" +const CheckinStatusPending = "pending" + +func ValidateCheckinEnabledSince(date string) error { + if date == "" { + return nil + } + if _, err := time.Parse("2006-01-02", date); err != nil { + return fmt.Errorf("enabled_since must be YYYY-MM-DD: %w", err) + } + return nil +} + +func (s *DB) GetCheckinEnabledSince() string { + return s.GetSetting(SettingSubjectiveCheckinEnabledSince, "") +} + +func (s *DB) SaveCheckinEnabledSince(date string) error { + if err := ValidateCheckinEnabledSince(date); err != nil { + return err + } + return s.SaveSettings(map[string]string{SettingSubjectiveCheckinEnabledSince: date}) +} // SaveCheckinPrompted upserts a `prompted` row for (date, source). // Idempotent on retry: a second prompt for the same date overwrites @@ -320,6 +349,10 @@ func (s *DB) GetCheckinCoverage(today, source string, days int) (*CheckinCoverag if _, err := time.Parse("2006-01-02", today); err != nil { return nil, fmt.Errorf("today must be YYYY-MM-DD: %w", err) } + enabledSince := s.GetCheckinEnabledSince() + if err := ValidateCheckinEnabledSince(enabledSince); err != nil { + return nil, err + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -358,7 +391,57 @@ func (s *DB) GetCheckinCoverage(today, source string, days int) (*CheckinCoverag if err := rows.Err(); err != nil { return nil, err } - return buildCheckinCoverage(today, source, days, entries) + coverage, err := buildCheckinCoverage(today, source, days, entries) + if err != nil { + return nil, err + } + coverage.EnabledSince = enabledSince + if enabledSince == "" { + return coverage, nil + } + + from := calendarCoverageStart(today, enabledSince, days) + calendarEntries, err := s.getCheckinRowsBetween(ctx, source, from, today) + if err != nil { + return nil, err + } + return buildCheckinCoverageWithSLA(coverage, today, from, source, enabledSince, calendarEntries) +} + +func (s *DB) getCheckinRowsBetween(ctx context.Context, source, from, to string) ([]CheckinRow, error) { + rows, err := s.pool.Query(ctx, ` + SELECT date, source, status, answer, prompt_message_id, prompted_at, answered_at, expires_at + FROM subjective_checkins + WHERE source = $1 + AND date >= $2 + AND date <= $3 + ORDER BY date DESC`, source, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []CheckinRow + for rows.Next() { + row := CheckinRow{} + var answer *string + var msgID *int64 + var answeredAt *time.Time + if err := rows.Scan(&row.Date, &row.Source, &row.Status, &answer, &msgID, &row.PromptedAt, &answeredAt, &row.ExpiresAt); err != nil { + return nil, err + } + if answer != nil { + row.Answer = *answer + } + if msgID != nil { + row.PromptMessageID = *msgID + } + if answeredAt != nil { + row.AnsweredAt = *answeredAt + } + entries = append(entries, row) + } + return entries, rows.Err() } func buildCheckinCoverage(today, source string, days int, entries []CheckinRow) (*CheckinCoverage, error) { @@ -442,6 +525,118 @@ func buildCheckinCoverage(today, source string, days int, entries []CheckinRow) return out, nil } +func calendarCoverageStart(today, enabledSince string, days int) string { + t, err := time.Parse("2006-01-02", today) + if err != nil { + return enabledSince + } + windowStart := t.AddDate(0, 0, -days+1).Format("2006-01-02") + if enabledSince > windowStart { + return enabledSince + } + return windowStart +} + +func buildCheckinCoverageWithSLA(base *CheckinCoverage, today, from, source, enabledSince string, entries []CheckinRow) (*CheckinCoverage, error) { + start, err := time.Parse("2006-01-02", from) + if err != nil { + return nil, fmt.Errorf("from must be YYYY-MM-DD: %w", err) + } + end, err := time.Parse("2006-01-02", today) + if err != nil { + return nil, fmt.Errorf("today must be YYYY-MM-DD: %w", err) + } + if end.Before(start) { + base.SLAActive = true + base.EnabledSince = enabledSince + base.SLASummary = CheckinCoverageSummary{AnswerCounts: map[string]int{}} + base.SLARows = []CheckinCoverageRow{} + return base, nil + } + + byDate := map[string]CheckinRow{} + for _, entry := range entries { + byDate[entry.Date] = entry + } + rows := make([]CheckinCoverageRow, 0, int(end.Sub(start).Hours()/24)+1) + summary := CheckinCoverageSummary{AnswerCounts: map[string]int{}} + var latencySum int64 + var latencyN int64 + promptedDays := 0 + for d := end; !d.Before(start); d = d.AddDate(0, 0, -1) { + date := d.Format("2006-01-02") + entry, ok := byDate[date] + if !ok { + status := CheckinStatusMissing + if date == today { + status = CheckinStatusPending + summary.Pending++ + } else { + summary.Missing++ + } + rows = append(rows, CheckinCoverageRow{Date: date, Source: source, Status: status}) + continue + } + row := checkinCoverageRow(entry) + rows = append(rows, row) + promptedDays++ + switch entry.Status { + case CheckinStatusPrompted: + summary.Prompted++ + case CheckinStatusAnswered: + summary.Answered++ + case CheckinStatusLateAnswered: + summary.LateAnswered++ + case CheckinStatusExpired: + summary.Expired++ + } + if entry.Answer != "" { + summary.AnswerCounts[entry.Answer]++ + } + if row.ResponseLatencySeconds != nil { + latencySum += *row.ResponseLatencySeconds + latencyN++ + } + } + summary.TotalDays = len(rows) + if latencyN > 0 { + avg := latencySum / latencyN + summary.AverageResponseSeconds = &avg + } + completedDays := summary.TotalDays - summary.Pending + summary.AnsweredCoveragePercent = percent(summary.Answered, completedDays) + summary.PromptedCoveragePercent = percent(promptedDays, completedDays) + + base.SLAActive = true + base.EnabledSince = enabledSince + base.SLASummary = summary + base.SLARows = rows + return base, nil +} + +func checkinCoverageRow(entry CheckinRow) CheckinCoverageRow { + promptedAt := entry.PromptedAt + expiresAt := entry.ExpiresAt + row := CheckinCoverageRow{ + Date: entry.Date, + Source: entry.Source, + Status: entry.Status, + Answer: entry.Answer, + PromptedAt: &promptedAt, + ExpiresAt: &expiresAt, + } + if !entry.AnsweredAt.IsZero() { + answeredAt := entry.AnsweredAt + row.AnsweredAt = &answeredAt + latency := int64(answeredAt.Sub(promptedAt).Seconds()) + if latency < 0 { + latency = 0 + } + row.ResponseLatencySeconds = &latency + } + return row +} + func percent(n, total int) int { if total <= 0 { return 0 diff --git a/internal/storage/subjective_checkin_test.go b/internal/storage/subjective_checkin_test.go index e4463f9..09b43f9 100644 --- a/internal/storage/subjective_checkin_test.go +++ b/internal/storage/subjective_checkin_test.go @@ -156,3 +156,68 @@ func TestBuildCheckinCoverage_LimitsToLatestRows(t *testing.T) { t.Fatalf("summary = %+v, want total=2 answered=2", coverage.Summary) } } + +func TestBuildCheckinCoverageWithSLA_SynthesizesMissingAfterEnabledSince(t *testing.T) { + prompted := time.Date(2026, 6, 16, 8, 0, 0, 0, time.UTC) + base, err := buildCheckinCoverage("2026-06-18", CheckinSourceTelegram, 14, nil) + if err != nil { + t.Fatalf("build history: %v", err) + } + coverage, err := buildCheckinCoverageWithSLA(base, "2026-06-18", "2026-06-15", CheckinSourceTelegram, "2026-06-15", []CheckinRow{ + {Date: "2026-06-16", Source: CheckinSourceTelegram, Status: CheckinStatusAnswered, Answer: CheckinAnswerOK, PromptedAt: prompted, AnsweredAt: prompted.Add(2 * time.Minute), ExpiresAt: prompted.Add(time.Hour)}, + {Date: "2026-06-15", Source: CheckinSourceTelegram, Status: CheckinStatusExpired, PromptedAt: prompted.AddDate(0, 0, -1), ExpiresAt: prompted.AddDate(0, 0, -1).Add(time.Hour)}, + }) + if err != nil { + t.Fatalf("build SLA: %v", err) + } + if !coverage.SLAActive || coverage.EnabledSince != "2026-06-15" { + t.Fatalf("sla active=%v enabled=%q", coverage.SLAActive, coverage.EnabledSince) + } + got := []string{ + coverage.SLARows[0].Date + ":" + coverage.SLARows[0].Status, + coverage.SLARows[1].Date + ":" + coverage.SLARows[1].Status, + coverage.SLARows[2].Date + ":" + coverage.SLARows[2].Status, + coverage.SLARows[3].Date + ":" + coverage.SLARows[3].Status, + } + want := []string{ + "2026-06-18:" + CheckinStatusPending, + "2026-06-17:" + CheckinStatusMissing, + "2026-06-16:" + CheckinStatusAnswered, + "2026-06-15:" + CheckinStatusExpired, + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("row %d = %q, want %q; all=%v", i, got[i], want[i], got) + } + } + if coverage.SLASummary.TotalDays != 4 || + coverage.SLASummary.Pending != 1 || + coverage.SLASummary.Missing != 1 || + coverage.SLASummary.Answered != 1 || + coverage.SLASummary.Expired != 1 { + t.Fatalf("sla summary = %+v", coverage.SLASummary) + } + if coverage.SLASummary.PromptedCoveragePercent != 67 { + t.Fatalf("prompted coverage = %d, want 67", coverage.SLASummary.PromptedCoveragePercent) + } +} + +func TestCalendarCoverageStart_ClampsToEnabledSince(t *testing.T) { + if got := calendarCoverageStart("2026-06-18", "2026-06-15", 14); got != "2026-06-15" { + t.Fatalf("start = %s, want enabled-since", got) + } + if got := calendarCoverageStart("2026-06-18", "2026-05-01", 3); got != "2026-06-16" { + t.Fatalf("start = %s, want window start", got) + } +} + +func TestValidateCheckinEnabledSince(t *testing.T) { + for _, date := range []string{"", "2026-06-18"} { + if err := ValidateCheckinEnabledSince(date); err != nil { + t.Fatalf("date %q should be valid: %v", date, err) + } + } + if err := ValidateCheckinEnabledSince("18-06-2026"); err == nil { + t.Fatal("invalid date should be rejected") + } +} diff --git a/internal/ui/admin_checkin_coverage_test.go b/internal/ui/admin_checkin_coverage_test.go index d142468..62006d9 100644 --- a/internal/ui/admin_checkin_coverage_test.go +++ b/internal/ui/admin_checkin_coverage_test.go @@ -1,6 +1,7 @@ package ui import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -52,6 +53,94 @@ func TestAdminCheckinCoverage_JSONShape(t *testing.T) { } } +func TestAdminCheckinCoverage_CalendarSLA(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + today := time.Now().UTC() + todayStr := today.Format("2006-01-02") + yesterdayStr := today.AddDate(0, 0, -1).Format("2006-01-02") + if err := db.SaveCheckinEnabledSince(yesterdayStr); err != nil { + t.Fatalf("save enabled since: %v", err) + } + promptedAt := time.Date(today.Year(), today.Month(), today.Day(), 8, 0, 0, 0, time.UTC) + if err := db.SaveCheckinPrompted(todayStr, storage.CheckinSourceTelegram, 42, promptedAt, promptedAt.Add(time.Hour)); err != nil { + t.Fatalf("seed prompt: %v", err) + } + if _, err := db.SaveCheckinAnswer(todayStr, storage.CheckinSourceTelegram, storage.CheckinAnswerOK, promptedAt.Add(time.Minute)); err != nil { + t.Fatalf("seed answer: %v", err) + } + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/admin/checkin-coverage?days=3", nil). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + var resp storage.CheckinCoverage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if !resp.SLAActive || resp.EnabledSince != yesterdayStr { + t.Fatalf("sla active=%v enabled_since=%q, want active %s", resp.SLAActive, resp.EnabledSince, yesterdayStr) + } + if len(resp.SLARows) != 2 { + t.Fatalf("sla rows = %d, want 2: %+v", len(resp.SLARows), resp.SLARows) + } + if resp.SLARows[0].Date != todayStr || resp.SLARows[0].Status != storage.CheckinStatusAnswered { + t.Fatalf("sla row0 = %+v, want today answered", resp.SLARows[0]) + } + if resp.SLARows[1].Date != yesterdayStr || resp.SLARows[1].Status != storage.CheckinStatusMissing { + t.Fatalf("sla row1 = %+v, want yesterday missing", resp.SLARows[1]) + } + if resp.SLASummary.Missing != 1 || resp.SLASummary.Answered != 1 { + t.Fatalf("sla summary = %+v, want missing=1 answered=1", resp.SLASummary) + } +} + +func TestAdminCheckinCoverage_PostSavesEnabledSince(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/admin/checkin-coverage?days=3", bytes.NewBufferString(`{"enabled_since":"2026-06-15"}`)). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if got := db.GetCheckinEnabledSince(); got != "2026-06-15" { + t.Fatalf("enabled_since = %q, want 2026-06-15", got) + } + var resp storage.CheckinCoverage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if !resp.SLAActive { + t.Fatalf("expected SLA active after POST: %+v", resp) + } +} + +func TestAdminCheckinCoverage_PostRejectsBadEnabledSince(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/admin/checkin-coverage", bytes.NewBufferString(`{"enabled_since":"15/06/2026"}`)). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + func TestAdminCheckinCoverage_RejectsBadDays(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/admin/checkin-coverage?days=0", nil) diff --git a/internal/ui/handler.go b/internal/ui/handler.go index 4f9854f..4ce5a8b 100644 --- a/internal/ui/handler.go +++ b/internal/ui/handler.go @@ -2865,7 +2865,7 @@ func (h *Handler) adminQualityDigest(w http.ResponseWriter, r *http.Request) { } func (h *Handler) adminCheckinCoverage(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -2883,6 +2883,20 @@ func (h *Handler) adminCheckinCoverage(w http.ResponseWriter, r *http.Request) { writeStatusError(w, scopeErr) return } + if r.Method == http.MethodPost { + var req struct { + EnabledSince string `json:"enabled_since"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.EnabledSince = strings.TrimSpace(req.EnabledSince) + if err := scope.DB.SaveCheckinEnabledSince(req.EnabledSince); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } today := tenantLocalToday(h, scope.DB, scope.Schema) coverage, err := scope.DB.GetCheckinCoverage(today, storage.CheckinSourceTelegram, days) if err != nil { diff --git a/internal/ui/i18n_en.go b/internal/ui/i18n_en.go index 6990ddb..ed27e01 100644 --- a/internal/ui/i18n_en.go +++ b/internal/ui/i18n_en.go @@ -370,7 +370,11 @@ var translationsEn = map[string]string{ "admin_quality_missed": "Missed nights", "admin_quality_fixed": "Flagged %d impossible, %d suspect rows.", "admin_checkin_coverage_title": "Subjective check-in coverage", - "admin_checkin_coverage_desc": "Read-only view of the latest Telegram morning prompts and response latency.", + "admin_checkin_coverage_desc": "Calendar SLA after the enabled-since date, plus latest Telegram morning prompts and response latency.", + "admin_checkin_enabled_since": "Enabled since", + "admin_checkin_calendar_sla": "Calendar SLA", + "admin_checkin_history": "Latest prompt history", + "admin_checkin_sla_inactive": "Set an enabled-since date to start calendar SLA checks.", "admin_checkin_total": "Days", "admin_checkin_prompted_coverage": "Prompted", "admin_checkin_answered_coverage": "Answered", @@ -386,6 +390,9 @@ var translationsEn = map[string]string{ "admin_checkin_status_late": "Late", "admin_checkin_status_expired": "Expired", "admin_checkin_status_missing": "Missing", + "admin_checkin_status_pending": "Pending", + "admin_save": "Save", + "admin_saved": "Saved", // ─── Admin: AI briefing settings ─────────────────────────── "admin_ai_title": "AI Morning Briefing", diff --git a/internal/ui/i18n_ru.go b/internal/ui/i18n_ru.go index 0f327ec..1e8e3f9 100644 --- a/internal/ui/i18n_ru.go +++ b/internal/ui/i18n_ru.go @@ -348,7 +348,11 @@ var translationsRu = map[string]string{ "admin_quality_missed": "Пропущенные ночи", "admin_quality_fixed": "Помечено: невозможных %d, подозрительных %d.", "admin_checkin_coverage_title": "Покрытие утренних check-in", - "admin_checkin_coverage_desc": "Только чтение: последние утренние Telegram-вопросы и задержка ответа.", + "admin_checkin_coverage_desc": "Календарный SLA после даты включения, плюс последние утренние Telegram-вопросы и задержка ответа.", + "admin_checkin_enabled_since": "Включено с", + "admin_checkin_calendar_sla": "Календарный SLA", + "admin_checkin_history": "История промптов", + "admin_checkin_sla_inactive": "Укажите дату включения, чтобы начать календарные SLA-проверки.", "admin_checkin_total": "Дней", "admin_checkin_prompted_coverage": "Промпт был", "admin_checkin_answered_coverage": "Ответ был", @@ -364,6 +368,9 @@ var translationsRu = map[string]string{ "admin_checkin_status_late": "Поздно", "admin_checkin_status_expired": "Истёк", "admin_checkin_status_missing": "Нет строки", + "admin_checkin_status_pending": "Ждём", + "admin_save": "Сохранить", + "admin_saved": "Сохранено", "admin_ai_title": "AI-брифинг утром", "admin_ai_key": "Gemini API ключ", diff --git a/internal/ui/i18n_sr.go b/internal/ui/i18n_sr.go index 0ecd4b9..5765356 100644 --- a/internal/ui/i18n_sr.go +++ b/internal/ui/i18n_sr.go @@ -329,7 +329,11 @@ var translationsSr = map[string]string{ "admin_quality_missed": "Propuštene noći", "admin_quality_fixed": "Označeno: nemoguće %d, sumnjive %d.", "admin_checkin_coverage_title": "Pokrivenost jutarnog check-ina", - "admin_checkin_coverage_desc": "Samo za čitanje: poslednja jutarnja Telegram pitanja i latencija odgovora.", + "admin_checkin_coverage_desc": "Kalendarski SLA posle datuma uključivanja, plus poslednja jutarnja Telegram pitanja i latencija odgovora.", + "admin_checkin_enabled_since": "Uključeno od", + "admin_checkin_calendar_sla": "Kalendarski SLA", + "admin_checkin_history": "Istorija promptova", + "admin_checkin_sla_inactive": "Unesite datum uključivanja da počnu kalendarske SLA provere.", "admin_checkin_total": "Dana", "admin_checkin_prompted_coverage": "Prompt poslat", "admin_checkin_answered_coverage": "Odgovoreno", @@ -345,6 +349,9 @@ var translationsSr = map[string]string{ "admin_checkin_status_late": "Kasno", "admin_checkin_status_expired": "Isteklo", "admin_checkin_status_missing": "Nema reda", + "admin_checkin_status_pending": "Čeka se", + "admin_save": "Sačuvaj", + "admin_saved": "Sačuvano", "admin_ai_title": "AI jutarnji izveštaj", "admin_ai_key": "Gemini API ključ", "admin_ai_model": "Model", diff --git a/internal/ui/style.go b/internal/ui/style.go index be57f00..fa253af 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -1118,6 +1118,21 @@ details.admin-section[open] > :not(summary) { margin-left: 16px; margin-right: 1 display: flex; gap: 24px; font-size: 13px; color: var(--muted); padding: 0 4px; } .admin-meta-row strong { color: var(--fg); } +.checkin-sla-settings { + display: flex; align-items: end; gap: 10px; flex-wrap: wrap; + margin: 0 0 14px; +} +.checkin-sla-settings label { + display: flex; flex-direction: column; gap: 4px; + font-size: 12px; color: var(--muted); +} +.checkin-sla-settings input { + border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); color: var(--fg); padding: 7px 9px; +} +.checkin-subtitle { + margin: 14px 0 8px; color: var(--fg); font-size: 14px; +} .checkin-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 10px; @@ -1142,6 +1157,7 @@ details.admin-section[open] > :not(summary) { margin-left: 16px; margin-right: 1 .checkin-status--prompted { background: var(--surface2); color: var(--text-secondary); } .checkin-status--expired { background: var(--low-bg); color: var(--low); } .checkin-status--missing { background: transparent; color: var(--text-tertiary); border: 1px dashed var(--border); } +.checkin-status--pending { background: var(--surface2); color: var(--text-tertiary); border: 1px solid var(--border); } .admin-monitoring-block { margin-bottom: 16px; } .admin-monitoring-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; diff --git a/internal/ui/templates/pages/admin.html b/internal/ui/templates/pages/admin.html index a26442c..cb590fa 100644 --- a/internal/ui/templates/pages/admin.html +++ b/internal/ui/templates/pages/admin.html @@ -118,6 +118,14 @@

{{T .Lang "admin_checkin_coverage_desc"}}

+
+ + + +
@@ -873,9 +881,35 @@ .catch(function(e) { el.innerHTML = '
' + escapeHtml(String(e.message || e)) + '
'; }); } +function saveCheckinEnabledSince() { + var input = $('checkin-enabled-since'); + var msg = $('checkin-enabled-msg'); + if (!input) return; + if (msg) msg.textContent = '...'; + fetchWithAdminSchema('/api/admin/checkin-coverage?days=14', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({enabled_since: input.value || ''}) + }) + .then(function(r){ + if (!r.ok) return r.text().then(function(t){ throw new Error(t || 'Failed to save check-in SLA date'); }); + return r.json(); + }) + .then(function(d) { + if (msg) msg.textContent = '{{T .Lang "admin_saved"}}'; + var el = $('admin-checkin-coverage-section'); + if (el) el.innerHTML = renderCheckinCoverage(d); + }) + .catch(function(e) { if (msg) msg.textContent = String(e.message || e); }); +} + function renderCheckinCoverage(d) { var s = d.summary || {}; + var sla = d.sla_summary || {}; var rows = d.rows || []; + var slaRows = d.sla_rows || []; + var enabledInput = $('checkin-enabled-since'); + if (enabledInput) enabledInput.value = d.enabled_since || ''; var labels = { total: '{{T .Lang "admin_checkin_total"}}', promptedCoverage: '{{T .Lang "admin_checkin_prompted_coverage"}}', @@ -883,6 +917,9 @@ avgResponse: '{{T .Lang "admin_checkin_avg_response"}}', noResponse: '{{T .Lang "admin_checkin_no_response"}}', answers: '{{T .Lang "admin_checkin_answers"}}', + calendarTitle: '{{T .Lang "admin_checkin_calendar_sla"}}', + historyTitle: '{{T .Lang "admin_checkin_history"}}', + inactive: '{{T .Lang "admin_checkin_sla_inactive"}}', date: '{{T .Lang "admin_checkin_date"}}', status: '{{T .Lang "admin_checkin_status"}}', answer: '{{T .Lang "admin_checkin_answer"}}', @@ -893,7 +930,8 @@ answered: '{{T .Lang "admin_checkin_status_answered"}}', late_answered: '{{T .Lang "admin_checkin_status_late"}}', expired: '{{T .Lang "admin_checkin_status_expired"}}', - missing: '{{T .Lang "admin_checkin_status_missing"}}' + missing: '{{T .Lang "admin_checkin_status_missing"}}', + pending: '{{T .Lang "admin_checkin_status_pending"}}' }; var answerLabels = { great: '{{T .Lang "checkin_answer_great"}}', @@ -910,41 +948,52 @@ var sec = Math.round(n % 60); return min + 'm ' + sec + 's'; }; - var answerCounts = s.answer_counts || {}; - var answerLine = ['great', 'ok', 'meh', 'sick'].map(function(k) { - return escapeHtml(answerLabels[k] || k) + ' ' + (answerCounts[k] || 0); - }).join(' · '); - - var html = '
' - + '
' + escapeHtml(labels.total) + '' + (s.total_days == null ? (d.days || 0) : s.total_days) + '
' - + '
' + escapeHtml(labels.promptedCoverage) + '' + (s.prompted_coverage_percent || 0) + '%
' - + '
' + escapeHtml(labels.answeredCoverage) + '' + (s.answered_coverage_percent || 0) + '%
' - + '
' + escapeHtml(labels.avgResponse) + '' + (s.average_response_seconds == null ? escapeHtml(labels.noResponse) : escapeHtml(fmtDuration(s.average_response_seconds))) + '
' - + '
' - + '
' + escapeHtml(labels.answers) + ' ' + answerLine + '
'; - - html += '' - + '' - + '' - + '' - + '' - + ''; var allowedStatusClass = { - prompted: true, answered: true, late_answered: true, expired: true, missing: true + prompted: true, answered: true, late_answered: true, expired: true, missing: true, pending: true }; - rows.forEach(function(row) { - var status = row.status || 'missing'; - var statusClass = allowedStatusClass[status] ? status : 'missing'; - var statusText = statusLabels[status] || status; - var answer = row.answer ? (answerLabels[row.answer] || row.answer) : '—'; - html += '' - + '' - + '' - + '' - + '' - + ''; - }); - html += '
' + escapeHtml(labels.date) + '' + escapeHtml(labels.status) + '' + escapeHtml(labels.answer) + '' + escapeHtml(labels.latency) + '
' + escapeHtml(row.date || '') + '' + escapeHtml(statusText) + '' + escapeHtml(answer) + '' + escapeHtml(fmtDuration(row.response_latency_seconds)) + '
'; + + function renderBlock(title, summary, tableRows, showInactive) { + if (showInactive) { + return '

' + escapeHtml(title) + '

' + + '
' + escapeHtml(labels.inactive) + '
'; + } + var answerCounts = summary.answer_counts || {}; + var answerLine = ['great', 'ok', 'meh', 'sick'].map(function(k) { + return escapeHtml(answerLabels[k] || k) + ' ' + (answerCounts[k] || 0); + }).join(' · '); + var html = '

' + escapeHtml(title) + '

' + + '
' + + '
' + escapeHtml(labels.total) + '' + (summary.total_days == null ? (d.days || 0) : summary.total_days) + '
' + + '
' + escapeHtml(labels.promptedCoverage) + '' + (summary.prompted_coverage_percent || 0) + '%
' + + '
' + escapeHtml(labels.answeredCoverage) + '' + (summary.answered_coverage_percent || 0) + '%
' + + '
' + escapeHtml(labels.avgResponse) + '' + (summary.average_response_seconds == null ? escapeHtml(labels.noResponse) : escapeHtml(fmtDuration(summary.average_response_seconds))) + '
' + + '
' + + '
' + escapeHtml(labels.answers) + ' ' + answerLine + '
'; + + html += '' + + '' + + '' + + '' + + '' + + ''; + tableRows.forEach(function(row) { + var status = row.status || 'missing'; + var statusClass = allowedStatusClass[status] ? status : 'missing'; + var statusText = statusLabels[status] || status; + var answer = row.answer ? (answerLabels[row.answer] || row.answer) : '—'; + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
' + escapeHtml(labels.date) + '' + escapeHtml(labels.status) + '' + escapeHtml(labels.answer) + '' + escapeHtml(labels.latency) + '
' + escapeHtml(row.date || '') + '' + escapeHtml(statusText) + '' + escapeHtml(answer) + '' + escapeHtml(fmtDuration(row.response_latency_seconds)) + '
'; + return html; + } + + var html = renderBlock(labels.calendarTitle, sla, slaRows, !d.sla_active) + + renderBlock(labels.historyTitle, s, rows, false); return html; } From a875de009f22662410f15408665f0edf7ef5a86a Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Thu, 18 Jun 2026 18:15:21 +0200 Subject: [PATCH 2/2] Address check-in SLA review feedback --- internal/storage/subjective_checkin.go | 48 ++++------------------ internal/ui/admin_checkin_coverage_test.go | 15 +++++++ internal/ui/handler.go | 10 ++++- internal/ui/i18n_en.go | 2 + internal/ui/i18n_ru.go | 2 + internal/ui/i18n_sr.go | 2 + internal/ui/templates/pages/admin.html | 4 +- 7 files changed, 40 insertions(+), 43 deletions(-) diff --git a/internal/storage/subjective_checkin.go b/internal/storage/subjective_checkin.go index 50ed829..6ab4401 100644 --- a/internal/storage/subjective_checkin.go +++ b/internal/storage/subjective_checkin.go @@ -368,27 +368,8 @@ func (s *DB) GetCheckinCoverage(today, source string, days int) (*CheckinCoverag } defer rows.Close() - var entries []CheckinRow - for rows.Next() { - row := CheckinRow{} - var answer *string - var msgID *int64 - var answeredAt *time.Time - if err := rows.Scan(&row.Date, &row.Source, &row.Status, &answer, &msgID, &row.PromptedAt, &answeredAt, &row.ExpiresAt); err != nil { - return nil, err - } - if answer != nil { - row.Answer = *answer - } - if msgID != nil { - row.PromptMessageID = *msgID - } - if answeredAt != nil { - row.AnsweredAt = *answeredAt - } - entries = append(entries, row) - } - if err := rows.Err(); err != nil { + entries, err := scanCheckinRows(rows) + if err != nil { return nil, err } coverage, err := buildCheckinCoverage(today, source, days, entries) @@ -420,7 +401,10 @@ func (s *DB) getCheckinRowsBetween(ctx context.Context, source, from, to string) return nil, err } defer rows.Close() + return scanCheckinRows(rows) +} +func scanCheckinRows(rows pgx.Rows) ([]CheckinRow, error) { var entries []CheckinRow for rows.Next() { row := CheckinRow{} @@ -476,25 +460,9 @@ func buildCheckinCoverage(today, source string, days int, entries []CheckinRow) var latencyN int64 promptedDays := 0 for _, entry := range entries { - promptedAt := entry.PromptedAt - expiresAt := entry.ExpiresAt - row := CheckinCoverageRow{ - Date: entry.Date, - Source: entry.Source, - Status: entry.Status, - Answer: entry.Answer, - PromptedAt: &promptedAt, - ExpiresAt: &expiresAt, - } - if !entry.AnsweredAt.IsZero() { - answeredAt := entry.AnsweredAt - row.AnsweredAt = &answeredAt - latency := int64(answeredAt.Sub(promptedAt).Seconds()) - if latency < 0 { - latency = 0 - } - row.ResponseLatencySeconds = &latency - latencySum += latency + row := checkinCoverageRow(entry) + if row.ResponseLatencySeconds != nil { + latencySum += *row.ResponseLatencySeconds latencyN++ } switch entry.Status { diff --git a/internal/ui/admin_checkin_coverage_test.go b/internal/ui/admin_checkin_coverage_test.go index 62006d9..6463c23 100644 --- a/internal/ui/admin_checkin_coverage_test.go +++ b/internal/ui/admin_checkin_coverage_test.go @@ -141,6 +141,21 @@ func TestAdminCheckinCoverage_PostRejectsBadEnabledSince(t *testing.T) { } } +func TestAdminCheckinCoverage_PostRejectsUnknownFields(t *testing.T) { + db, schema, cleanup := testTenantDB(t) + defer cleanup() + + h := &Handler{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/admin/checkin-coverage", bytes.NewBufferString(`{"enabled_since":"2026-06-15","extra":true}`)). + WithContext(adminContext(db, schema)) + h.adminCheckinCoverage(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + func TestAdminCheckinCoverage_RejectsBadDays(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/admin/checkin-coverage?days=0", nil) diff --git a/internal/ui/handler.go b/internal/ui/handler.go index 4ce5a8b..20621b9 100644 --- a/internal/ui/handler.go +++ b/internal/ui/handler.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "html/template" + "io" "math" "net" "net/http" @@ -2887,7 +2888,14 @@ func (h *Handler) adminCheckinCoverage(w http.ResponseWriter, r *http.Request) { var req struct { EnabledSince string `json:"enabled_since"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + const maxBodyBytes = 1024 + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if err := dec.Decode(&struct{}{}); err != io.EOF { http.Error(w, "invalid JSON", http.StatusBadRequest) return } diff --git a/internal/ui/i18n_en.go b/internal/ui/i18n_en.go index ed27e01..2fa5e43 100644 --- a/internal/ui/i18n_en.go +++ b/internal/ui/i18n_en.go @@ -375,6 +375,7 @@ var translationsEn = map[string]string{ "admin_checkin_calendar_sla": "Calendar SLA", "admin_checkin_history": "Latest prompt history", "admin_checkin_sla_inactive": "Set an enabled-since date to start calendar SLA checks.", + "admin_checkin_save_failed": "Failed to save check-in SLA date", "admin_checkin_total": "Days", "admin_checkin_prompted_coverage": "Prompted", "admin_checkin_answered_coverage": "Answered", @@ -393,6 +394,7 @@ var translationsEn = map[string]string{ "admin_checkin_status_pending": "Pending", "admin_save": "Save", "admin_saved": "Saved", + "admin_saving": "Saving...", // ─── Admin: AI briefing settings ─────────────────────────── "admin_ai_title": "AI Morning Briefing", diff --git a/internal/ui/i18n_ru.go b/internal/ui/i18n_ru.go index 1e8e3f9..27c60f5 100644 --- a/internal/ui/i18n_ru.go +++ b/internal/ui/i18n_ru.go @@ -353,6 +353,7 @@ var translationsRu = map[string]string{ "admin_checkin_calendar_sla": "Календарный SLA", "admin_checkin_history": "История промптов", "admin_checkin_sla_inactive": "Укажите дату включения, чтобы начать календарные SLA-проверки.", + "admin_checkin_save_failed": "Не удалось сохранить дату SLA check-in", "admin_checkin_total": "Дней", "admin_checkin_prompted_coverage": "Промпт был", "admin_checkin_answered_coverage": "Ответ был", @@ -371,6 +372,7 @@ var translationsRu = map[string]string{ "admin_checkin_status_pending": "Ждём", "admin_save": "Сохранить", "admin_saved": "Сохранено", + "admin_saving": "Сохраняем...", "admin_ai_title": "AI-брифинг утром", "admin_ai_key": "Gemini API ключ", diff --git a/internal/ui/i18n_sr.go b/internal/ui/i18n_sr.go index 5765356..c01a4ec 100644 --- a/internal/ui/i18n_sr.go +++ b/internal/ui/i18n_sr.go @@ -334,6 +334,7 @@ var translationsSr = map[string]string{ "admin_checkin_calendar_sla": "Kalendarski SLA", "admin_checkin_history": "Istorija promptova", "admin_checkin_sla_inactive": "Unesite datum uključivanja da počnu kalendarske SLA provere.", + "admin_checkin_save_failed": "Čuvanje SLA datuma check-ina nije uspelo", "admin_checkin_total": "Dana", "admin_checkin_prompted_coverage": "Prompt poslat", "admin_checkin_answered_coverage": "Odgovoreno", @@ -352,6 +353,7 @@ var translationsSr = map[string]string{ "admin_checkin_status_pending": "Čeka se", "admin_save": "Sačuvaj", "admin_saved": "Sačuvano", + "admin_saving": "Čuvanje...", "admin_ai_title": "AI jutarnji izveštaj", "admin_ai_key": "Gemini API ključ", "admin_ai_model": "Model", diff --git a/internal/ui/templates/pages/admin.html b/internal/ui/templates/pages/admin.html index cb590fa..f8df24c 100644 --- a/internal/ui/templates/pages/admin.html +++ b/internal/ui/templates/pages/admin.html @@ -885,14 +885,14 @@ var input = $('checkin-enabled-since'); var msg = $('checkin-enabled-msg'); if (!input) return; - if (msg) msg.textContent = '...'; + if (msg) msg.textContent = '{{T .Lang "admin_saving"}}'; fetchWithAdminSchema('/api/admin/checkin-coverage?days=14', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({enabled_since: input.value || ''}) }) .then(function(r){ - if (!r.ok) return r.text().then(function(t){ throw new Error(t || 'Failed to save check-in SLA date'); }); + if (!r.ok) return r.text().then(function(t){ throw new Error(t || '{{T .Lang "admin_checkin_save_failed"}}'); }); return r.json(); }) .then(function(d) {