feat: add manual interval creation and inline entry editing

- Service: CreateInterval() validates same-day, end>start, day not closed
- Service: Update() now rejects edits on closed-day entries (ErrDayAlreadyClosed)
- Handler: POST /api/entries creates a completed interval with explicit times
- API client: entries.createInterval(startMs, endMs, note)
- Utils: parseTimeInput / toTimeInput helpers for HH:MM <-> unix ms
- Today page: '+ Add interval' form (time pickers + optional note)
- Today page: pencil button on each entry opens inline edit row (start/end/note)
- Tests: TestCreateInterval, TestCreateIntervalEndBeforeStart,
         TestCreateIntervalCrossesMidnight, TestUpdateRejectsClosedDay
This commit is contained in:
2026-04-30 17:45:02 +02:00
parent 245edf1534
commit 9d6233b116
6 changed files with 393 additions and 25 deletions

View File

@@ -19,12 +19,47 @@ func NewEntryHandler(svc *service.EntryService) *EntryHandler {
func (h *EntryHandler) Routes(r chi.Router) {
r.Post("/entries/start", h.Start)
r.Post("/entries", h.CreateInterval)
r.Post("/entries/{id}/stop", h.StopByID)
r.Get("/entries", h.List)
r.Put("/entries/{id}", h.Update)
r.Delete("/entries/{id}", h.Delete)
}
// CreateInterval POST /api/entries — create a completed interval with explicit times
func (h *EntryHandler) CreateInterval(w http.ResponseWriter, r *http.Request) {
var body struct {
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
Note string `json:"note"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.StartTime == 0 || body.EndTime == 0 {
writeError(w, http.StatusBadRequest, "start_time and end_time are required")
return
}
entry, err := h.svc.CreateInterval(r.Context(), service.CreateIntervalInput{
StartTime: body.StartTime,
EndTime: body.EndTime,
Note: body.Note,
})
if err != nil {
switch {
case errors.Is(err, service.ErrCrossesMidnight):
writeError(w, http.StatusUnprocessableEntity, err.Error())
case errors.Is(err, service.ErrDayAlreadyClosed):
writeError(w, http.StatusConflict, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
return
}
writeJSON(w, http.StatusCreated, entry)
}
// Start POST /api/entries/start
func (h *EntryHandler) Start(w http.ResponseWriter, r *http.Request) {
var body struct {

View File

@@ -176,6 +176,15 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
return nil, err
}
// Disallow editing entries that belong to a closed day.
closed, err := s.closedDays.GetByDayKey(ctx, e.DayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if closed != nil {
return nil, ErrDayAlreadyClosed
}
if input.StartTime != nil {
e.StartTime = *input.StartTime
e.DayKey = s.dayKeyForMs(e.StartTime)
@@ -203,6 +212,50 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
return e, nil
}
// CreateIntervalInput holds fields for a manually created completed interval.
type CreateIntervalInput struct {
StartTime int64
EndTime int64
Note string
}
// CreateInterval adds a completed interval with explicit start and end times.
// Rules: same-day, end > start, day not closed, no currently running entry on that day (check running globally).
func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalInput) (*domain.Entry, error) {
if input.EndTime <= input.StartTime {
return nil, fmt.Errorf("end_time must be after start_time")
}
startDayKey := s.dayKeyForMs(input.StartTime)
endDayKey := s.dayKeyForMs(input.EndTime)
if startDayKey != endDayKey {
return nil, ErrCrossesMidnight
}
// Check day is not closed.
closed, err := s.closedDays.GetByDayKey(ctx, startDayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if closed != nil {
return nil, ErrDayAlreadyClosed
}
nowMs := s.nowMs()
e := &domain.Entry{
ID: uuid.New().String(),
StartTime: input.StartTime,
EndTime: &input.EndTime,
Note: input.Note,
DayKey: startDayKey,
UpdatedAt: nowMs,
}
if err := s.entries.Create(ctx, e); err != nil {
return nil, err
}
return e, nil
}
// Delete soft-deletes an entry.
func (s *EntryService) Delete(ctx context.Context, id string) error {
nowMs := s.nowMs()

View File

@@ -2,6 +2,7 @@ package service_test
import (
"context"
"errors"
"testing"
"time"
@@ -134,3 +135,98 @@ func TestListEntries(t *testing.T) {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
}
func TestCreateInterval(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
now := time.Now().UTC()
startMs := now.Add(-2 * time.Hour).UnixMilli()
endMs := now.Add(-1 * time.Hour).UnixMilli()
entry, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: startMs,
EndTime: endMs,
Note: "manual entry",
})
if err != nil {
t.Fatalf("CreateInterval: %v", err)
}
if entry.EndTime == nil {
t.Fatal("expected EndTime to be set")
}
if *entry.EndTime != endMs {
t.Errorf("expected EndTime %d, got %d", endMs, *entry.EndTime)
}
if entry.Note != "manual entry" {
t.Errorf("expected note 'manual entry', got %q", entry.Note)
}
}
func TestCreateIntervalEndBeforeStart(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
now := time.Now().UTC().UnixMilli()
_, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: now,
EndTime: now - 1000,
})
if err == nil {
t.Fatal("expected error for end_time before start_time")
}
}
func TestCreateIntervalCrossesMidnight(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
yesterday := time.Now().UTC().Add(-24 * time.Hour)
startMs := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 0, 0, 0, time.UTC).UnixMilli()
endMs := time.Now().UTC().Add(time.Hour).UnixMilli()
_, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: startMs,
EndTime: endMs,
})
if err == nil {
t.Fatal("expected ErrCrossesMidnight")
}
if !errors.Is(err, service.ErrCrossesMidnight) {
t.Fatalf("expected ErrCrossesMidnight, got %v", err)
}
}
func TestUpdateRejectsClosedDay(t *testing.T) {
ctx := context.Background()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
entryStore := store.NewEntryStore(db)
closedDayStore := store.NewClosedDayStore(db)
settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC")
svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
entry, _ := svc.Start(ctx, "")
svc.Stop(ctx)
today := time.Now().UTC().Format("2006-01-02")
if _, err := daySvc.CloseDay(ctx, today); err != nil {
t.Fatalf("CloseDay: %v", err)
}
note := "should fail"
_, err = svc.Update(ctx, entry.ID, service.UpdateEntryInput{Note: &note})
if err == nil {
t.Fatal("expected error updating entry in closed day")
}
if !errors.Is(err, service.ErrDayAlreadyClosed) {
t.Fatalf("expected ErrDayAlreadyClosed, got %v", err)
}
}