diff --git a/internal/handler/entry_handler.go b/internal/handler/entry_handler.go index f414a19..92703a2 100644 --- a/internal/handler/entry_handler.go +++ b/internal/handler/entry_handler.go @@ -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 { diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go index 322e155..76e0dfd 100644 --- a/internal/service/entry_service.go +++ b/internal/service/entry_service.go @@ -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() diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go index 1a07cda..34f8371 100644 --- a/internal/service/entry_service_test.go +++ b/internal/service/entry_service_test.go @@ -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: ¬e}) + 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) + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index ca8d53a..7e9339b 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -85,6 +85,8 @@ export interface Settings { export const entries = { start: (note = '') => request('POST', '/entries/start', { note }), + createInterval: (startTime: number, endTime: number, note = '') => + request('POST', '/entries', { start_time: startTime, end_time: endTime, note }), stop: (id: string) => request('POST', `/entries/${id}/stop`), list: (from?: string, to?: string) => { const params = new URLSearchParams(); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 600f51d..d091317 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -71,7 +71,21 @@ export function formatDelta(ms: number): string { return `${sign}${formatDurationShort(Math.abs(ms))}`; } -/** Workday bit mask: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64 */ +/** Parse "HH:MM" time string on a given dayKey (YYYY-MM-DD) into unix ms (local time). */ +export function parseTimeInput(dayKey: string, hhmm: string): number | null { + if (!/^\d{2}:\d{2}$/.test(hhmm)) return null; + const [h, m] = hhmm.split(':').map(Number); + if (h > 23 || m > 59) return null; + const d = new Date(`${dayKey}T${hhmm}:00`); + return isNaN(d.getTime()) ? null : d.getTime(); +} + +/** Format unix ms as "HH:MM" in local time (for time inputs). */ +export function toTimeInput(ms: number): string { + const d = new Date(ms); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64]; // Mon..Sun /** JS getDay() → our bit (Sun=0 in JS → bit 64) */ diff --git a/web/src/routes/today/+page.svelte b/web/src/routes/today/+page.svelte index f7a0997..32c7d65 100644 --- a/web/src/routes/today/+page.svelte +++ b/web/src/routes/today/+page.svelte @@ -1,7 +1,7 @@