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:
@@ -19,12 +19,47 @@ func NewEntryHandler(svc *service.EntryService) *EntryHandler {
|
|||||||
|
|
||||||
func (h *EntryHandler) Routes(r chi.Router) {
|
func (h *EntryHandler) Routes(r chi.Router) {
|
||||||
r.Post("/entries/start", h.Start)
|
r.Post("/entries/start", h.Start)
|
||||||
|
r.Post("/entries", h.CreateInterval)
|
||||||
r.Post("/entries/{id}/stop", h.StopByID)
|
r.Post("/entries/{id}/stop", h.StopByID)
|
||||||
r.Get("/entries", h.List)
|
r.Get("/entries", h.List)
|
||||||
r.Put("/entries/{id}", h.Update)
|
r.Put("/entries/{id}", h.Update)
|
||||||
r.Delete("/entries/{id}", h.Delete)
|
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
|
// Start POST /api/entries/start
|
||||||
func (h *EntryHandler) Start(w http.ResponseWriter, r *http.Request) {
|
func (h *EntryHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
|
|||||||
@@ -176,6 +176,15 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
|
|||||||
return nil, err
|
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 {
|
if input.StartTime != nil {
|
||||||
e.StartTime = *input.StartTime
|
e.StartTime = *input.StartTime
|
||||||
e.DayKey = s.dayKeyForMs(e.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
|
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.
|
// Delete soft-deletes an entry.
|
||||||
func (s *EntryService) Delete(ctx context.Context, id string) error {
|
func (s *EntryService) Delete(ctx context.Context, id string) error {
|
||||||
nowMs := s.nowMs()
|
nowMs := s.nowMs()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -134,3 +135,98 @@ func TestListEntries(t *testing.T) {
|
|||||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface Settings {
|
|||||||
|
|
||||||
export const entries = {
|
export const entries = {
|
||||||
start: (note = '') => request<Entry>('POST', '/entries/start', { note }),
|
start: (note = '') => request<Entry>('POST', '/entries/start', { note }),
|
||||||
|
createInterval: (startTime: number, endTime: number, note = '') =>
|
||||||
|
request<Entry>('POST', '/entries', { start_time: startTime, end_time: endTime, note }),
|
||||||
stop: (id: string) => request<Entry>('POST', `/entries/${id}/stop`),
|
stop: (id: string) => request<Entry>('POST', `/entries/${id}/stop`),
|
||||||
list: (from?: string, to?: string) => {
|
list: (from?: string, to?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -71,7 +71,21 @@ export function formatDelta(ms: number): string {
|
|||||||
return `${sign}${formatDurationShort(Math.abs(ms))}`;
|
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
|
export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64]; // Mon..Sun
|
||||||
|
|
||||||
/** JS getDay() → our bit (Sun=0 in JS → bit 64) */
|
/** JS getDay() → our bit (Sun=0 in JS → bit 64) */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
|
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
|
||||||
import { todayKey, formatTime, formatDuration, formatDurationShort } from '$lib/utils';
|
import { todayKey, formatTime, formatDuration, formatDurationShort, parseTimeInput, toTimeInput } from '$lib/utils';
|
||||||
|
|
||||||
let today = todayKey();
|
let today = todayKey();
|
||||||
let entryList: Entry[] = $state([]);
|
let entryList: Entry[] = $state([]);
|
||||||
@@ -12,6 +12,20 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
|
// Add-interval form
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
let addStart = $state('');
|
||||||
|
let addEnd = $state('');
|
||||||
|
let addNote = $state('');
|
||||||
|
let addError = $state('');
|
||||||
|
|
||||||
|
// Inline edit state
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editStart = $state('');
|
||||||
|
let editEnd = $state('');
|
||||||
|
let editNote = $state('');
|
||||||
|
let editError = $state('');
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -112,6 +126,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Add interval ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openAddForm() {
|
||||||
|
addStart = '';
|
||||||
|
addEnd = '';
|
||||||
|
addNote = '';
|
||||||
|
addError = '';
|
||||||
|
showAddForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddForm() {
|
||||||
|
showAddForm = false;
|
||||||
|
addError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddInterval() {
|
||||||
|
addError = '';
|
||||||
|
const startMs = parseTimeInput(today, addStart);
|
||||||
|
const endMs = parseTimeInput(today, addEnd);
|
||||||
|
if (startMs === null) { addError = 'Invalid start time'; return; }
|
||||||
|
if (endMs === null) { addError = 'Invalid end time'; return; }
|
||||||
|
if (endMs <= startMs) { addError = 'End must be after start'; return; }
|
||||||
|
try {
|
||||||
|
const e = await entries.createInterval(startMs, endMs, addNote);
|
||||||
|
entryList = [...entryList, e].sort((a, b) => a.start_time - b.start_time);
|
||||||
|
closeAddForm();
|
||||||
|
} catch (e) {
|
||||||
|
addError = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline edit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startEdit(entry: Entry) {
|
||||||
|
editingId = entry.id;
|
||||||
|
editStart = toTimeInput(entry.start_time);
|
||||||
|
editEnd = entry.end_time ? toTimeInput(entry.end_time) : '';
|
||||||
|
editNote = entry.note;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(entry: Entry) {
|
||||||
|
editError = '';
|
||||||
|
const startMs = parseTimeInput(today, editStart);
|
||||||
|
if (startMs === null) { editError = 'Invalid start time'; return; }
|
||||||
|
|
||||||
|
// end_time: only required if the entry is not running
|
||||||
|
let endMs: number | undefined;
|
||||||
|
if (!entry.end_time && editEnd === '') {
|
||||||
|
// still running, no end — that's fine, don't send end_time
|
||||||
|
} else {
|
||||||
|
const parsed = parseTimeInput(today, editEnd);
|
||||||
|
if (parsed === null) { editError = 'Invalid end time'; return; }
|
||||||
|
if (parsed <= startMs) { editError = 'End must be after start'; return; }
|
||||||
|
endMs = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await entries.update(entry.id, {
|
||||||
|
start_time: startMs,
|
||||||
|
...(endMs !== undefined ? { end_time: endMs } : {}),
|
||||||
|
note: editNote
|
||||||
|
});
|
||||||
|
entryList = entryList
|
||||||
|
.map((e) => (e.id === updated.id ? updated : e))
|
||||||
|
.sort((a, b) => a.start_time - b.start_time);
|
||||||
|
if (running?.id === updated.id) running = updated;
|
||||||
|
editingId = null;
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalWorkedMs = $derived(
|
const totalWorkedMs = $derived(
|
||||||
entryList.reduce((sum, e) => {
|
entryList.reduce((sum, e) => {
|
||||||
if (e.end_time === null) return sum;
|
if (e.end_time === null) return sum;
|
||||||
@@ -166,11 +258,59 @@
|
|||||||
|
|
||||||
<!-- Entry list -->
|
<!-- Entry list -->
|
||||||
<section class="entries">
|
<section class="entries">
|
||||||
|
<div class="entries-header">
|
||||||
<h2>Entries</h2>
|
<h2>Entries</h2>
|
||||||
|
{#if !closedDay}
|
||||||
|
<button class="btn-add" onclick={openAddForm} title="Add interval">+ Add interval</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add-interval form -->
|
||||||
|
{#if showAddForm}
|
||||||
|
<div class="interval-form">
|
||||||
|
<h3>Add interval</h3>
|
||||||
|
{#if addError}<p class="form-error">{addError}</p>{/if}
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
Start
|
||||||
|
<input type="time" bind:value={addStart} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End
|
||||||
|
<input type="time" bind:value={addEnd} required />
|
||||||
|
</label>
|
||||||
|
<label class="note-label">
|
||||||
|
Note
|
||||||
|
<input type="text" bind:value={addNote} placeholder="Optional" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn save" onclick={handleAddInterval}>Save</button>
|
||||||
|
<button class="btn cancel" onclick={closeAddForm}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if entryList.length === 0}
|
{#if entryList.length === 0}
|
||||||
<p class="empty">No entries yet today.</p>
|
<p class="empty">No entries yet today.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each entryList as entry (entry.id)}
|
{#each entryList as entry (entry.id)}
|
||||||
|
{#if editingId === entry.id}
|
||||||
|
<!-- Inline edit row -->
|
||||||
|
<div class="entry editing">
|
||||||
|
{#if editError}<p class="form-error">{editError}</p>{/if}
|
||||||
|
<div class="edit-row">
|
||||||
|
<input type="time" bind:value={editStart} class="time-input" />
|
||||||
|
<span class="dash">→</span>
|
||||||
|
<input type="time" bind:value={editEnd} class="time-input" placeholder="--:--" disabled={!entry.end_time && editEnd === ''} />
|
||||||
|
<input type="text" bind:value={editNote} class="note-input" placeholder="Note" />
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="btn save sm" onclick={() => saveEdit(entry)}>Save</button>
|
||||||
|
<button class="btn cancel sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="entry" class:running={entry.end_time === null}>
|
<div class="entry" class:running={entry.end_time === null}>
|
||||||
<span class="time">{formatTime(entry.start_time)}</span>
|
<span class="time">{formatTime(entry.start_time)}</span>
|
||||||
<span class="dash">→</span>
|
<span class="dash">→</span>
|
||||||
@@ -185,9 +325,11 @@
|
|||||||
<span class="badge auto">auto-stopped</span>
|
<span class="badge auto">auto-stopped</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !closedDay}
|
{#if !closedDay}
|
||||||
|
<button class="edit-btn" onclick={() => startEdit(entry)} title="Edit">✎</button>
|
||||||
<button class="del" onclick={() => handleDeleteEntry(entry.id)} title="Delete">✕</button>
|
<button class="del" onclick={() => handleDeleteEntry(entry.id)} title="Delete">✕</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<div class="total">Total: {formatDurationShort(totalWorkedMs)}</div>
|
<div class="total">Total: {formatDurationShort(totalWorkedMs)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -196,7 +338,8 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
|
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
|
||||||
h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; color: #495057; }
|
h2 { font-size: 1rem; margin: 0; color: #495057; }
|
||||||
|
h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #343a40; }
|
||||||
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
|
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
|
||||||
.closed-banner { display: flex; align-items: center; justify-content: space-between;
|
.closed-banner { display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-weight: 500; }
|
padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-weight: 500; }
|
||||||
@@ -210,24 +353,49 @@
|
|||||||
.timer.idle { color: #6c757d; }
|
.timer.idle { color: #6c757d; }
|
||||||
.start-row { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem; }
|
.start-row { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem; }
|
||||||
.start-row input { padding: 0.5rem 0.75rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem; width: 220px; }
|
.start-row input { padding: 0.5rem 0.75rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem; width: 220px; }
|
||||||
.btn { padding: 0.55rem 1.4rem; border: none; border-radius: 6px; font-size: 1rem; font-weight: 600; transition: opacity 0.15s; }
|
.btn { padding: 0.55rem 1.4rem; border: none; border-radius: 6px; font-size: 1rem; font-weight: 600; transition: opacity 0.15s; cursor: pointer; }
|
||||||
.btn:hover { opacity: 0.88; }
|
.btn:hover { opacity: 0.88; }
|
||||||
.btn.start { background: #2c7be5; color: #fff; margin-top: 1rem; }
|
.btn.start { background: #2c7be5; color: #fff; margin-top: 1rem; }
|
||||||
.btn.stop { background: #e74c3c; color: #fff; margin-top: 1rem; }
|
.btn.stop { background: #e74c3c; color: #fff; margin-top: 1rem; }
|
||||||
.btn.close-day { background: #343a40; color: #fff; margin-top: 1rem; display: block; }
|
.btn.close-day { background: #343a40; color: #fff; margin-top: 1rem; display: block; }
|
||||||
|
.btn.save { background: #2c7be5; color: #fff; }
|
||||||
|
.btn.cancel { background: #e9ecef; color: #343a40; }
|
||||||
|
.btn.sm { padding: 0.3rem 0.8rem; font-size: 0.875rem; }
|
||||||
.quick-mark { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
.quick-mark { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
.quick-mark span { color: #6c757d; font-size: 0.875rem; }
|
.quick-mark span { color: #6c757d; font-size: 0.875rem; }
|
||||||
.quick-mark button { padding: 0.35rem 0.75rem; border: 1px solid #ced4da; background: #fff; border-radius: 6px; font-size: 0.875rem; }
|
.quick-mark button { padding: 0.35rem 0.75rem; border: 1px solid #ced4da; background: #fff; border-radius: 6px; font-size: 0.875rem; cursor: pointer; }
|
||||||
.quick-mark button:hover { background: #e9ecef; }
|
.quick-mark button:hover { background: #e9ecef; }
|
||||||
.entries { border-top: 1px solid #dee2e6; margin-top: 1.5rem; padding-top: 0.5rem; }
|
.entries { border-top: 1px solid #dee2e6; margin-top: 1.5rem; padding-top: 0.75rem; }
|
||||||
|
.entries-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||||
|
.btn-add { background: none; border: 1px solid #2c7be5; color: #2c7be5; border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.75rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-add:hover { background: #eaf3ff; }
|
||||||
|
/* Add-interval form */
|
||||||
|
.interval-form { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;
|
||||||
|
padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
.form-row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.form-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: #495057; }
|
||||||
|
.form-row input[type="time"], .form-row input[type="text"] {
|
||||||
|
padding: 0.4rem 0.6rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.9rem; }
|
||||||
|
.note-label { flex: 1; min-width: 160px; }
|
||||||
|
.form-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||||
|
.form-error { color: #c0392b; font-size: 0.85rem; margin: 0 0 0.5rem; }
|
||||||
|
/* Entry rows */
|
||||||
.entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
|
.entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
|
||||||
.entry.running { background: #eaf3ff; border-radius: 6px; padding: 0.5rem 0.5rem; }
|
.entry.running { background: #eaf3ff; border-radius: 6px; padding: 0.5rem 0.5rem; }
|
||||||
|
.entry.editing { display: block; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.25rem; }
|
||||||
|
.edit-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.time-input { width: 90px; padding: 0.3rem 0.4rem; border: 1px solid #ced4da; border-radius: 5px; font-size: 0.9rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.note-input { flex: 1; min-width: 120px; padding: 0.3rem 0.5rem; border: 1px solid #ced4da; border-radius: 5px; font-size: 0.9rem; }
|
||||||
|
.edit-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
||||||
.time { font-variant-numeric: tabular-nums; color: #343a40; }
|
.time { font-variant-numeric: tabular-nums; color: #343a40; }
|
||||||
.dash { color: #adb5bd; }
|
.dash { color: #adb5bd; }
|
||||||
.dur { font-variant-numeric: tabular-nums; color: #6c757d; margin-left: auto; }
|
.dur { font-variant-numeric: tabular-nums; color: #6c757d; margin-left: auto; }
|
||||||
.note { color: #495057; font-style: italic; }
|
.note { color: #495057; font-style: italic; }
|
||||||
.badge.auto { font-size: 0.75rem; background: #fff3cd; color: #856404; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
.badge.auto { font-size: 0.75rem; background: #fff3cd; color: #856404; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||||
.del { margin-left: 0.5rem; background: none; border: none; color: #adb5bd; font-size: 1rem; padding: 0 0.25rem; }
|
.edit-btn { margin-left: auto; background: none; border: none; color: #adb5bd; font-size: 1rem; padding: 0 0.25rem; cursor: pointer; }
|
||||||
|
.edit-btn:hover { color: #2c7be5; }
|
||||||
|
.del { background: none; border: none; color: #adb5bd; font-size: 1rem; padding: 0 0.25rem; cursor: pointer; }
|
||||||
.del:hover { color: #e74c3c; }
|
.del:hover { color: #e74c3c; }
|
||||||
.total { text-align: right; padding: 0.5rem 0; color: #495057; font-weight: 600; }
|
.total { text-align: right; padding: 0.5rem 0; color: #495057; font-weight: 600; }
|
||||||
.empty { color: #6c757d; font-style: italic; }
|
.empty { color: #6c757d; font-style: italic; }
|
||||||
|
|||||||
Reference in New Issue
Block a user