Skip to content

Commit 693b93e

Browse files
authored
feat: admin-defined focus range for recordings (#306)
* feat: add focus_start/focus_end columns (migration v9) * feat: update all queries and scan for focus_start/focus_end columns * feat: extend edit endpoint for focus range (set/clear) * feat: accept focusStart/focusEnd in upload endpoint * feat: add focusStart/focusEnd to Recording type and API client * feat: add focus range icons and CSS styles * feat: implement focus range UI (toolbar, timeline overlays, shortcuts) Add FocusToolbar component for editing in/out points, timeline scrubber overlays with drag handles, FOCUS/FULL toggle, and I/O/Esc keyboard shortcuts. Admins can set, save, clear, and cancel focus ranges that persist via the edit API. * fix: match focus range UI to design concept - Move Panel button from left to right in controls row - Focus toolbar: space-between layout with label left, buttons right - Gold-styled Set In / Set Out buttons (matching design) - Add gold border-bottom on focus toolbar row - Dynamic --pb-bottom-height when editing (fixes overflow) - Return 400 on malformed focus values instead of silent ignore - Consistent 1-frame min gap for handle drag and keyboard * fix: focus range time color, handle style, keyboard hints - Time range text color #887744 matching design - Handles: 8px wide, extend beyond track, rounded outside edge, inner grip line, gold glow shadow - Add kbd hints: Set In [I], Set Out [O], Cancel [Esc], Panel [E] * feat: constrain playback to focus range in FOCUS mode When FOCUS toggle is active (not FULL, not editing): - Scrubber zooms into focus range (full width = inFrame..outFrame) - Heatmap and kill markers only show events within range - Playback pauses when reaching outFrame - Seeking via scrubber is clamped to focus range - Dim overlays hidden (entire scrubber IS the focus range) Switch to FULL mode to see/seek the entire recording. * fix: smooth transition on all elements using --pb-bottom-height Add transition: bottom 0.15s ease to MapControls, SidePanel, FollowIndicator, and leaflet controls so they animate together with the bottom bar when focus toolbar toggles. * refactor: extract gold accent color to --accent-gold CSS variable Replace all hardcoded #D4A843 and rgba(212,168,67,...) in focus range styles with var(--accent-gold) and color-mix() derivations. * docs: add --accent-focus to customization reference * fix: address PR review feedback (error handling, SQL DRY) - Check json.Unmarshal errors for missionName/tag/date fields - Check I/O errors in test helper (gw.Write, gw.Close, writer.Close) - Check repo.db.Close() error in test cleanup - Extract duplicated SQL column list to operationColumns constant - Log errors in saveFocus/clearFocus catch blocks * test: add coverage for focus range feature - FocusToolbar: 11 tests (rendering, callbacks, styling, kbd hints) - TimelineScrubber constrained mode: 5 tests (progress mapping, dim overlay suppression, kill marker filtering) - Go handler: table-driven test for invalid field types (missionName, tag, date, focusStart, focusEnd) * test: cover remaining focus range gaps across all files - shortcuts.ts: 5 tests for focus editing keys (i, o, Escape) - BottomBar.tsx: 8 tests for FocusToolbar visibility, Focus button, FOCUS toggle, admin gating - TimelineScrubber.tsx: 9 tests for focus overlays, accent line, edit mode handles/labels/border, heatmap dimming - RecordingPlayback.tsx: 2 tests for focus range initialization from metadata and admin Focus button rendering * fix: address 3 bugs found during focus range testing Bug 1: Deduplicate setFocusIn/setFocusOut/cancelFocus — shortcuts registered in onMount used inline lambdas duplicating the named functions. Now defined once above onMount and referenced by name. Bug 2: Remove fragile focusRange()! null assertions — replaced with defensive null checks in the clamping effect and startFocusEdit. Bug 3: Reject inverted focus ranges server-side — both EditOperation and StoreOperation now return 400 when focusStart >= focusEnd. Includes regression test (TestEditOperation_InvertedFocusRange). * test: add coverage for focus handle dragging, kill nav, and edit flow - TimelineScrubber: 5 tests for handle drag (pointerDown/Move/Up on in/out handles, draft change callbacks, drag state reset) - BottomBar: 2 tests for prev-kill and next-kill button navigation - RecordingPlayback: 3 integration tests for focus edit flow (open+cancel, save with API call, clear with null values) Total: 1403 tests passing * test: close remaining coverage gaps for focus range feature Go: - TestStoreOperation_InvertedFocusRange: validates upload endpoint rejects focusStart >= focusEnd UI (RecordingPlayback): - toggleBlacklist un-blacklist path (DELETE API call) - saveFocus API error handling (console.error, edit mode stays open) - clearFocus API error handling (console.error logged) Total: 1406 tests passing * test: cover showFullTimeline toggle and FOCUS/FULL switch - BottomBar: 2 tests for showFullTimeline=true (FULL text, null focusRange passed to scrubber) - RecordingPlayback: FOCUS toggle integration test (click switches to FULL text) Total: 1409 tests passing * fix: clamp navigation to focus range, reject partial focus in API, extract constrainToFocus prop - Clamp frame to focus bounds on any seek (not just during playback) - Reject partial focusStart/focusEnd in both upload and edit endpoints - Extract constrainToFocus as BottomBar prop to eliminate DRY violation - Add tests reproducing all three bugs before fixing * fix: use undefined instead of null for optional entity fields in tests * test: cover setFocusIn, setFocusOut, and pause-at-outFrame paths
1 parent 0bc4a1a commit 693b93e

26 files changed

Lines changed: 2559 additions & 67 deletions

docs/customization.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Return ALL of these keys with appropriate values:
4242
--accent-success Success/positive state color
4343
--accent-success-dark Darker success variant
4444
--accent-warning Warning/caution color
45+
--accent-focus Focus range accent (handles, toolbar, toggle)
4546
--text-primary Brightest text (headings, body)
4647
--text-secondary Slightly dimmer text
4748
--text-muted Muted labels, placeholders
@@ -70,6 +71,7 @@ This is the built-in blue theme. Your output should differ from this:
7071
"--accent-success": "#2DD4A0",
7172
"--accent-success-dark": "#1a9a74",
7273
"--accent-warning": "#FFB84A",
74+
"--accent-focus": "#D4A843",
7375
"--text-primary": "#e5ebf1",
7476
"--text-secondary": "#cfd9e4",
7577
"--text-muted": "#96a7b8",
@@ -183,7 +185,8 @@ You only need to override the variables you want to change — everything else k
183185
"--bg-surface-hover": "#3a4a3a",
184186
"--bg-interactive": "rgba(255, 255, 255, 0.04)",
185187
"--bg-interactive-hover": "rgba(255, 255, 255, 0.08)",
186-
"--bg-modal-header": "rgba(107, 142, 35, 0.9)"
188+
"--bg-modal-header": "rgba(107, 142, 35, 0.9)",
189+
"--accent-focus": "#B8962E"
187190
}
188191
}
189192
}
@@ -217,7 +220,8 @@ You only need to override the variables you want to change — everything else k
217220
"--bg-surface-hover": "#2A2A2F",
218221
"--bg-interactive": "rgba(212, 106, 46, 0.06)",
219222
"--bg-interactive-hover": "rgba(212, 106, 46, 0.12)",
220-
"--bg-modal-header": "rgba(180, 80, 30, 0.85)"
223+
"--bg-modal-header": "rgba(180, 80, 30, 0.85)",
224+
"--accent-focus": "#C44040"
221225
}
222226
}
223227
}
@@ -265,6 +269,7 @@ Every field can also be set via environment variables with the `OCAP_` prefix. E
265269
| `--accent-warning` | `#FFB84A` | Warning indicators |
266270
| `--accent-danger` | `#FF4A4A` | Error states, delete actions |
267271
| `--accent-danger-dark` | `#CC3333` | Hover states for danger elements |
272+
| `--accent-focus` | `#D4A843` | Focus range UI (handles, toolbar, toggle) |
268273

269274
### CSS Variables — Text Colors
270275

internal/server/handler.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,29 @@ func (h *Handler) StoreOperation(c echo.Context) error {
347347
return err
348348
}
349349

350+
if fs := c.FormValue("focusStart"); fs != "" {
351+
v, err := strconv.ParseInt(fs, 10, 64)
352+
if err != nil {
353+
return echo.ErrBadRequest
354+
}
355+
op.FocusStart = &v
356+
}
357+
if fe := c.FormValue("focusEnd"); fe != "" {
358+
v, err := strconv.ParseInt(fe, 10, 64)
359+
if err != nil {
360+
return echo.ErrBadRequest
361+
}
362+
op.FocusEnd = &v
363+
}
364+
365+
// Validate focus range: both must be present or both absent, and start < end
366+
if (op.FocusStart == nil) != (op.FocusEnd == nil) {
367+
return echo.ErrBadRequest
368+
}
369+
if op.FocusStart != nil && op.FocusEnd != nil && *op.FocusStart >= *op.FocusEnd {
370+
return echo.ErrBadRequest
371+
}
372+
350373
if err = h.repoOperation.Store(ctx, &op); err != nil {
351374
return err
352375
}

internal/server/handler_admin.go

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package server
22

33
import (
4+
"encoding/json"
5+
"fmt"
46
"net/http"
57
"os"
68
"path/filepath"
@@ -13,6 +15,69 @@ type editOperationRequest struct {
1315
MissionName string `json:"missionName"`
1416
Tag string `json:"tag"`
1517
Date string `json:"date"`
18+
19+
// Focus range fields use json.RawMessage to distinguish
20+
// "field absent" (don't change) from "field is null" (clear value).
21+
// Populated manually from raw JSON; not decoded by struct tags.
22+
FocusStart json.RawMessage `json:"-"`
23+
FocusEnd json.RawMessage `json:"-"`
24+
25+
hasFocusStart bool
26+
hasFocusEnd bool
27+
}
28+
29+
// parseFocusField parses a nullable int64 from a json.RawMessage that is known to be present.
30+
// Returns nil for JSON null, or the parsed int64 value.
31+
func parseFocusField(raw json.RawMessage) (*int64, bool) {
32+
if string(raw) == "null" {
33+
return nil, true // explicitly null — clear the value
34+
}
35+
var v int64
36+
if err := json.Unmarshal(raw, &v); err != nil {
37+
return nil, false
38+
}
39+
return &v, true
40+
}
41+
42+
// decodeEditRequest decodes the JSON body into an editOperationRequest,
43+
// tracking whether focusStart/focusEnd keys were present.
44+
func decodeEditRequest(c echo.Context) (editOperationRequest, error) {
45+
var req editOperationRequest
46+
47+
// Decode into a raw map to detect key presence
48+
var rawMap map[string]json.RawMessage
49+
if err := json.NewDecoder(c.Request().Body).Decode(&rawMap); err != nil {
50+
return req, err
51+
}
52+
53+
// Decode standard string fields from the raw map
54+
if v, ok := rawMap["missionName"]; ok {
55+
if err := json.Unmarshal(v, &req.MissionName); err != nil {
56+
return req, fmt.Errorf("invalid missionName: %w", err)
57+
}
58+
}
59+
if v, ok := rawMap["tag"]; ok {
60+
if err := json.Unmarshal(v, &req.Tag); err != nil {
61+
return req, fmt.Errorf("invalid tag: %w", err)
62+
}
63+
}
64+
if v, ok := rawMap["date"]; ok {
65+
if err := json.Unmarshal(v, &req.Date); err != nil {
66+
return req, fmt.Errorf("invalid date: %w", err)
67+
}
68+
}
69+
70+
// Track focus field presence (key exists in JSON, even if value is null)
71+
if v, ok := rawMap["focusStart"]; ok {
72+
req.FocusStart = v
73+
req.hasFocusStart = true
74+
}
75+
if v, ok := rawMap["focusEnd"]; ok {
76+
req.FocusEnd = v
77+
req.hasFocusEnd = true
78+
}
79+
80+
return req, nil
1681
}
1782

1883
// EditOperation updates the editable metadata of an operation.
@@ -22,8 +87,8 @@ func (h *Handler) EditOperation(c echo.Context) error {
2287
return echo.ErrBadRequest
2388
}
2489

25-
var req editOperationRequest
26-
if err := c.Bind(&req); err != nil {
90+
req, err := decodeEditRequest(c)
91+
if err != nil {
2792
return echo.ErrBadRequest
2893
}
2994

@@ -43,13 +108,41 @@ func (h *Handler) EditOperation(c echo.Context) error {
43108
date = current.Date
44109
}
45110

46-
if err := h.repoOperation.UpdateOperation(c.Request().Context(), id, name, tag, date); err != nil {
111+
// Focus range: only update if the field was present in the JSON body
112+
focusStart := current.FocusStart
113+
focusEnd := current.FocusEnd
114+
if req.hasFocusStart {
115+
val, ok := parseFocusField(req.FocusStart)
116+
if !ok {
117+
return echo.ErrBadRequest
118+
}
119+
focusStart = val
120+
}
121+
if req.hasFocusEnd {
122+
val, ok := parseFocusField(req.FocusEnd)
123+
if !ok {
124+
return echo.ErrBadRequest
125+
}
126+
focusEnd = val
127+
}
128+
129+
// Validate focus range: both must be present or both absent, and start < end
130+
if (focusStart == nil) != (focusEnd == nil) {
131+
return echo.ErrBadRequest
132+
}
133+
if focusStart != nil && focusEnd != nil && *focusStart >= *focusEnd {
134+
return echo.ErrBadRequest
135+
}
136+
137+
if err := h.repoOperation.UpdateOperation(c.Request().Context(), id, name, tag, date, focusStart, focusEnd); err != nil {
47138
return err
48139
}
49140

50141
current.MissionName = name
51142
current.Tag = tag
52143
current.Date = date
144+
current.FocusStart = focusStart
145+
current.FocusEnd = focusEnd
53146

54147
return c.JSON(http.StatusOK, current)
55148
}

internal/server/handler_admin_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,190 @@ func TestDeleteOperation_DBDeleteError(t *testing.T) {
635635
assert.Equal(t, http.StatusNoContent, rec.Code)
636636
}
637637

638+
func TestEditOperation_FocusRange(t *testing.T) {
639+
hdlr, op := setupAdminTest(t)
640+
token, err := hdlr.jwt.Create("")
641+
require.NoError(t, err)
642+
643+
e := echo.New()
644+
body := `{"focusStart":50,"focusEnd":420}`
645+
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(body))
646+
req.Header.Set("Content-Type", "application/json")
647+
req.Header.Set("Authorization", "Bearer "+token)
648+
rec := httptest.NewRecorder()
649+
c := e.NewContext(req, rec)
650+
c.SetParamNames("id")
651+
c.SetParamValues(fmt.Sprintf("%d", op.ID))
652+
653+
err = hdlr.EditOperation(c)
654+
require.NoError(t, err)
655+
assert.Equal(t, http.StatusOK, rec.Code)
656+
657+
updated, err := hdlr.repoOperation.GetByID(t.Context(), fmt.Sprintf("%d", op.ID))
658+
require.NoError(t, err)
659+
require.NotNil(t, updated.FocusStart)
660+
require.NotNil(t, updated.FocusEnd)
661+
assert.Equal(t, int64(50), *updated.FocusStart)
662+
assert.Equal(t, int64(420), *updated.FocusEnd)
663+
664+
var result Operation
665+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
666+
require.NotNil(t, result.FocusStart)
667+
assert.Equal(t, int64(50), *result.FocusStart)
668+
}
669+
670+
func TestEditOperation_ClearFocusRange(t *testing.T) {
671+
hdlr, op := setupAdminTest(t)
672+
token, err := hdlr.jwt.Create("")
673+
require.NoError(t, err)
674+
ctx := t.Context()
675+
676+
// First set a focus range directly in DB
677+
start, end := int64(10), int64(100)
678+
_, err = hdlr.repoOperation.db.ExecContext(ctx,
679+
`UPDATE operations SET focus_start = ?, focus_end = ? WHERE id = ?`,
680+
start, end, op.ID)
681+
require.NoError(t, err)
682+
683+
// Clear via API with explicit nulls
684+
e := echo.New()
685+
body := `{"focusStart":null,"focusEnd":null}`
686+
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(body))
687+
req.Header.Set("Content-Type", "application/json")
688+
req.Header.Set("Authorization", "Bearer "+token)
689+
rec := httptest.NewRecorder()
690+
c := e.NewContext(req, rec)
691+
c.SetParamNames("id")
692+
c.SetParamValues(fmt.Sprintf("%d", op.ID))
693+
694+
err = hdlr.EditOperation(c)
695+
require.NoError(t, err)
696+
assert.Equal(t, http.StatusOK, rec.Code)
697+
698+
updated, err := hdlr.repoOperation.GetByID(ctx, fmt.Sprintf("%d", op.ID))
699+
require.NoError(t, err)
700+
assert.Nil(t, updated.FocusStart)
701+
assert.Nil(t, updated.FocusEnd)
702+
}
703+
704+
func TestEditOperation_PreservesFocusRange(t *testing.T) {
705+
hdlr, op := setupAdminTest(t)
706+
token, err := hdlr.jwt.Create("")
707+
require.NoError(t, err)
708+
ctx := t.Context()
709+
710+
// Set focus range directly
711+
start, end := int64(10), int64(100)
712+
_, err = hdlr.repoOperation.db.ExecContext(ctx,
713+
`UPDATE operations SET focus_start = ?, focus_end = ? WHERE id = ?`,
714+
start, end, op.ID)
715+
require.NoError(t, err)
716+
717+
// Edit only missionName — focus range should be preserved
718+
e := echo.New()
719+
body := `{"missionName":"Renamed"}`
720+
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(body))
721+
req.Header.Set("Content-Type", "application/json")
722+
req.Header.Set("Authorization", "Bearer "+token)
723+
rec := httptest.NewRecorder()
724+
c := e.NewContext(req, rec)
725+
c.SetParamNames("id")
726+
c.SetParamValues(fmt.Sprintf("%d", op.ID))
727+
728+
err = hdlr.EditOperation(c)
729+
require.NoError(t, err)
730+
731+
updated, err := hdlr.repoOperation.GetByID(ctx, fmt.Sprintf("%d", op.ID))
732+
require.NoError(t, err)
733+
assert.Equal(t, "Renamed", updated.MissionName)
734+
require.NotNil(t, updated.FocusStart)
735+
assert.Equal(t, int64(10), *updated.FocusStart)
736+
assert.Equal(t, int64(100), *updated.FocusEnd)
737+
}
738+
739+
func TestEditOperation_InvertedFocusRange(t *testing.T) {
740+
hdlr, op := setupAdminTest(t)
741+
token, err := hdlr.jwt.Create("")
742+
require.NoError(t, err)
743+
744+
e := echo.New()
745+
// focusStart > focusEnd — this is an invalid range
746+
body := `{"focusStart":420,"focusEnd":50}`
747+
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(body))
748+
req.Header.Set("Content-Type", "application/json")
749+
req.Header.Set("Authorization", "Bearer "+token)
750+
rec := httptest.NewRecorder()
751+
c := e.NewContext(req, rec)
752+
c.SetParamNames("id")
753+
c.SetParamValues(fmt.Sprintf("%d", op.ID))
754+
755+
err = hdlr.EditOperation(c)
756+
assert.Error(t, err, "inverted focus range should be rejected")
757+
}
758+
759+
func TestEditOperation_PartialFocusRange(t *testing.T) {
760+
hdlr, op := setupAdminTest(t)
761+
token, err := hdlr.jwt.Create("")
762+
require.NoError(t, err)
763+
764+
tests := []struct {
765+
name string
766+
body string
767+
}{
768+
{"focusStart without focusEnd", `{"focusStart":50}`},
769+
{"focusEnd without focusStart", `{"focusEnd":100}`},
770+
}
771+
772+
for _, tc := range tests {
773+
t.Run(tc.name, func(t *testing.T) {
774+
e := echo.New()
775+
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(tc.body))
776+
req.Header.Set("Content-Type", "application/json")
777+
req.Header.Set("Authorization", "Bearer "+token)
778+
rec := httptest.NewRecorder()
779+
c := e.NewContext(req, rec)
780+
c.SetParamNames("id")
781+
c.SetParamValues(fmt.Sprintf("%d", op.ID))
782+
783+
err := hdlr.EditOperation(c)
784+
assert.Error(t, err, "partial focus range should be rejected: %s", tc.name)
785+
})
786+
}
787+
}
788+
789+
func TestEditOperation_InvalidFieldTypes(t *testing.T) {
790+
tests := []struct {
791+
name string
792+
body string
793+
}{
794+
{"invalid missionName type", `{"missionName": 123}`},
795+
{"invalid tag type", `{"tag": []}`},
796+
{"invalid date type", `{"date": {"nested": true}}`},
797+
{"invalid focusStart type", `{"focusStart": "not-a-number"}`},
798+
{"invalid focusEnd type", `{"focusEnd": ["array"]}`},
799+
}
800+
801+
for _, tt := range tests {
802+
t.Run(tt.name, func(t *testing.T) {
803+
hdlr, op := setupAdminTest(t)
804+
token, err := hdlr.jwt.Create("")
805+
require.NoError(t, err)
806+
807+
e := echo.New()
808+
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(tt.body))
809+
req.Header.Set("Content-Type", "application/json")
810+
req.Header.Set("Authorization", "Bearer "+token)
811+
rec := httptest.NewRecorder()
812+
c := e.NewContext(req, rec)
813+
c.SetParamNames("id")
814+
c.SetParamValues(fmt.Sprintf("%d", op.ID))
815+
816+
err = hdlr.EditOperation(c)
817+
assert.Error(t, err, "expected error for %s", tt.name)
818+
})
819+
}
820+
}
821+
638822
func TestDeleteOperation_ReadOnlyFileCleanup(t *testing.T) {
639823
dir := t.TempDir()
640824
dataDir := filepath.Join(dir, "data")

0 commit comments

Comments
 (0)