refactored timetracking to only use client-supplied times

This commit is contained in:
2025-08-28 22:52:04 +02:00
parent 3a43c5a7ba
commit dabd5eef0b
10 changed files with 176 additions and 124 deletions

View File

@@ -3,7 +3,6 @@ package de.srsoftware.umbrella.core.model;
import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Util.*; import static de.srsoftware.umbrella.core.Util.*;
import static java.time.ZoneOffset.UTC;
import de.srsoftware.tools.Mappable; import de.srsoftware.tools.Mappable;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
@@ -18,8 +17,8 @@ import org.json.JSONObject;
public class Time implements Mappable{ public class Time implements Mappable{
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final HashSet<Long> taskIds = new HashSet<>(); private final HashSet<Long> taskIds = new HashSet<>();
private LocalDateTime end; private long start;
private LocalDateTime start; private Long end;
private long id; private long id;
private final long userId; private final long userId;
private String description; private String description;
@@ -33,7 +32,7 @@ public class Time implements Mappable{
Complete(60), Complete(60),
Cancelled(100); Cancelled(100);
private int code; private final int code;
State(int code){ State(int code){
this.code = code; this.code = code;
@@ -55,7 +54,7 @@ public class Time implements Mappable{
} }
} }
public Time(long id, long userId, String subject, String description, LocalDateTime start, LocalDateTime end, State state, Collection<Long> taskIds){ public Time(long id, long userId, String subject, String description, long start, Long end, State state, Collection<Long> taskIds){
this.id=id; this.id=id;
this.userId = userId; this.userId = userId;
this.subject = subject; this.subject = subject;
@@ -70,14 +69,10 @@ public class Time implements Mappable{
return description; return description;
} }
public LocalDateTime end(){ public Long end(){
return end; return end;
} }
public Long endSecond(){
return end == null ? null : end.toEpochSecond(UTC);
}
public long id(){ public long id(){
return id; return id;
} }
@@ -90,11 +85,10 @@ public class Time implements Mappable{
} }
public static Time of(ResultSet rs) throws SQLException { public static Time of(ResultSet rs) throws SQLException {
var startTimestamp = rs.getLong(START_TIME); var start = rs.getLong(START_TIME);
var start = startTimestamp == 0 ? null : LocalDateTime.ofEpochSecond(startTimestamp,0,UTC); Long end = rs.getLong(END_TIME);
var endTimestamp = rs.getLong(END_TIME);
var end = endTimestamp == 0 ? null : LocalDateTime.ofEpochSecond(endTimestamp,0,UTC); if (end == 0) end = null;
return new Time( return new Time(
rs.getLong(ID), rs.getLong(ID),
@@ -120,9 +114,9 @@ public class Time implements Mappable{
if (o instanceof JSONObject nested && nested.get(SOURCE) instanceof String src) o = src; if (o instanceof JSONObject nested && nested.get(SOURCE) instanceof String src) o = src;
if (o instanceof String d) description = d; if (o instanceof String d) description = d;
} }
if (json.has(START_TIME) && json.get(START_TIME) instanceof String st) start = parse(st); if (json.has(START_TIME) && json.get(START_TIME) instanceof Number st) start = st.longValue();
if (json.has(END_TIME) && json.get(END_TIME) instanceof String e) end = parse(e); if (json.has(END_TIME) && json.get(END_TIME) instanceof Number e) end = e.longValue();
if (end != null && end.isBefore(start)) throw UmbrellaException.invalidFieldException(END_TIME,"after start_time"); if (end != null && end < start) throw UmbrellaException.invalidFieldException(END_TIME,"after start_time");
return this; return this;
} }
@@ -130,21 +124,18 @@ public class Time implements Mappable{
id = newValue; id = newValue;
} }
public LocalDateTime start(){ public long start(){
return start; return start;
} }
public Long startSecond(){
return start == null ? null : start.toEpochSecond(UTC);
}
public State state(){ public State state(){
return state; return state;
} }
public Time stop(LocalDateTime endTime) { public Time stop(long endTime) {
end = endTime.withSecond(0).withNano(0); end = endTime;
start = start.withSecond(0).withNano(0);
state = State.Open; state = State.Open;
return this; return this;
} }
@@ -164,10 +155,10 @@ public class Time implements Mappable{
map.put(USER_ID,userId); map.put(USER_ID,userId);
map.put(SUBJECT,subject); map.put(SUBJECT,subject);
map.put(DESCRIPTION,mapMarkdown(description)); map.put(DESCRIPTION,mapMarkdown(description));
map.put(START_TIME,start.toString().replace("T"," ")); map.put(START_TIME,start);
map.put(END_TIME,end == null ? null : end.toString().replace("T"," ")); map.put(END_TIME,end);
map.put(STATE,Map.of(STATUS_CODE,state.code,NAME,state.name())); map.put(STATE,Map.of(STATUS_CODE,state.code,NAME,state.name()));
map.put(DURATION,end == null ? null : Duration.between(start,end).toMinutes()/60d); map.put(DURATION,end == null ? null : (end - start)/3600d);
map.put(TASK_IDS,taskIds); map.put(TASK_IDS,taskIds);
return map; return map;
} }

View File

@@ -2,10 +2,10 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router'; import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../urls.svelte.js';
import { logout, user } from '../user.svelte.js'; import { logout, user } from '../user.svelte.js';
import { t } from '../translations.svelte.js'; import { t } from '../translations.svelte.js';
import { timetrack } from '../user.svelte.js';
import TimeRecorder from './TimeRecorder.svelte';
let key = $state(null); let key = $state(null);
const router = useTinyRouter(); const router = useTinyRouter();
@@ -30,56 +30,12 @@ function go(path){
return false; return false;
} }
function msToTime(ms) {
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
ms %= 24 * 60 * 60 * 1000;
const hours = Math.floor(ms / (60 * 60 * 1000));
ms %= 60 * 60 * 1000;
const minutes = Math.floor(ms / (60 * 1000));
ms %= 60 * 1000;
const seconds = Math.floor(ms / 1000);
let daysStr = days > 0 ? padTo2Digits(days) + ':' : '';
return `${daysStr}${padTo2Digits(hours)}:${padTo2Digits(minutes)}:${padTo2Digits(seconds)}`;
}
function padTo2Digits(num) {
return num.toString().padStart(2, '0');
}
async function search(e){ async function search(e){
e.preventDefault(); e.preventDefault();
router.navigate(`/search?key=${key}`); router.navigate(`/search?key=${key}`);
return false; return false;
} }
async function stopTrack(){
if (timetrack.running.id){
const url = api(`time/${timetrack.running.id}/stop`);
const res = await fetch(url,{credentials:'include'});
if (res.ok){
timetrack.running = null;
timetrack.elapsed = null;
timetrack.start = null;
router.navigate('/time');
}
}
}
let interval = null;
$effect(() => {
if (timetrack.running) {
console.log('effect!');
timetrack.start = Date.parse(timetrack.running.start_time);
interval = setInterval(() => { timetrack.elapsed = msToTime(Date.now() - timetrack.start); },1000);
} else {
clearInterval(interval);
timetrack.elapsed = null;
interval = null;
}
});
onMount(fetchModules); onMount(fetchModules);
onDestroy(() => { onDestroy(() => {
@@ -113,9 +69,5 @@ onDestroy(() => {
{#if user.name } {#if user.name }
<a onclick={logout}>{t('logout')}</a> <a onclick={logout}>{t('logout')}</a>
{/if} {/if}
{#if timetrack.running} <TimeRecorder />
<span class="timetracking">{timetrack.elapsed} {timetrack.running.subject}
<button onclick={stopTrack} title={t('stop')} class="symbol"></button>
</span>
{/if}
</nav> </nav>

View File

@@ -1,6 +1,8 @@
<script> <script>
import { display } from '../time.svelte.js';
import { t } from '../translations.svelte.js'; import { t } from '../translations.svelte.js';
import MarkdownEditor from './MarkdownEditor.svelte'; import MarkdownEditor from './MarkdownEditor.svelte';
import TimeStampInput from './TimeStampInput.svelte';
let { record = null, onSet = time => {} } = $props(); let { record = null, onSet = time => {} } = $props();
@@ -20,11 +22,11 @@
</label> </label>
<label> <label>
{t('start')} {t('start')}
<input type="datetime-local" bind:value={record.start_time} /> <TimeStampInput bind:timestamp={record.start_time} />
</label> </label>
<label> <label>
{t('end')} {t('end')}
<input type="datetime-local" bind:value={record.end_time} /> <TimeStampInput bind:timestamp={record.end_time} />
</label> </label>
<label> <label>
{t('description')} {t('description')}

View File

@@ -0,0 +1,55 @@
<script>
import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../urls.svelte';
import { t } from '../translations.svelte';
import { msToTime, now } from '../time.svelte';
import { timetrack } from '../user.svelte';
let interval = null;
const router = useTinyRouter();
async function stopTrack(){
if (timetrack.running.id){
const url = api(`time/${timetrack.running.id}/stop`);
const resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : now()
});
if (resp.ok){
timetrack.running = null;
timetrack.elapsed = null;
timetrack.start = null;
go();
}
}
}
function go(){
router.navigate('/time');
}
function updateElapsed(){
timetrack.elapsed = msToTime(Date.now() - timetrack.start);
}
$effect(() => {
if (timetrack.running) {
timetrack.start = new Date(timetrack.running.start_time*1000);
interval = setInterval(updateElapsed,1000);
} else {
clearInterval(interval);
timetrack.elapsed = null;
interval = null;
}
});
</script>
{#if timetrack.running}
<span class="timetracking">
<span onclick={go} >{timetrack.running.subject} {#if timetrack.elapsed}({timetrack.elapsed}){/if}</span>
<button onclick={stopTrack} title={t('stop')} class="symbol"></button>
</span>
{/if}

View File

@@ -0,0 +1,16 @@
<script>
import { display, to_secs } from '../time.svelte.js';
let { timestamp = $bindable() } = $props();
let datetime = $state(null);
$effect(() => {
datetime = display(timestamp);
});
$effect(() => {
timestamp = to_secs(datetime);
});
</script>
<input type="datetime-local" bind:value={datetime} />

View File

@@ -2,10 +2,11 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router'; import { useTinyRouter } from 'svelte-tiny-router';
import { dragged } from './dragndrop.svelte.js'; import { dragged } from './dragndrop.svelte';
import { api } from '../../urls.svelte.js'; import { api } from '../../urls.svelte';
import { t } from '../../translations.svelte.js'; import { t } from '../../translations.svelte';
import { timetrack } from '../../user.svelte.js'; import { timetrack } from '../../user.svelte';
import { now } from '../../time.svelte';
import TaskList from './TaskList.svelte'; import TaskList from './TaskList.svelte';
import LineEditor from '../../Components/LineEditor.svelte'; import LineEditor from '../../Components/LineEditor.svelte';
@@ -29,11 +30,14 @@
async function addTime(){ async function addTime(){
const url = api(`time/track_task/${task.id}`); const url = api(`time/track_task/${task.id}`);
const resp = await fetch(url,{credentials:'include'}); // create new time or return time with assigned tasks const resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : now()
}); // create new time or return time with assigned tasks
if (resp.ok) { if (resp.ok) {
const track = await resp.json(); const track = await resp.json();
timetrack.running = track; timetrack.running = track;
console.log(track);
} else { } else {
error = await resp.text(); error = await resp.text();
} }

View File

@@ -3,6 +3,7 @@
import { useTinyRouter } from 'svelte-tiny-router'; import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../../urls.svelte.js'; import { api } from '../../urls.svelte.js';
import { t } from '../../translations.svelte.js'; import { t } from '../../translations.svelte.js';
import { display } from '../../time.svelte.js';
import TimeEditor from '../../Components/TimeRecordEditor.svelte'; import TimeEditor from '../../Components/TimeRecordEditor.svelte';
@@ -10,7 +11,7 @@
let router = useTinyRouter(); let router = useTinyRouter();
let times = $state(null); let times = $state(null);
let detail = $state(null); let detail = $state(null);
let sortedTimes = $derived.by(() => Object.values(times).sort((b, a) => a.start_time.localeCompare(b.start_time))); let sortedTimes = $derived.by(() => Object.values(times).sort((b, a) => a.start_time - b.start_time));
let selected = $state({}); let selected = $state({});
let ranges = {}; let ranges = {};
let timeMap = $derived.by(calcYearMap); let timeMap = $derived.by(calcYearMap);
@@ -29,7 +30,7 @@
let monthIndex = 0; let monthIndex = 0;
for (let idx in sortedTimes){ for (let idx in sortedTimes){
const time = sortedTimes[idx]; const time = sortedTimes[idx];
const start = time.start_time; const start = display(time.start_time);
const year = start.substring(0,4); const year = start.substring(0,4);
const month = start.substring(5,7); const month = start.substring(5,7);
if (year != lastYear){ if (year != lastYear){
@@ -52,6 +53,7 @@
return result; return result;
} }
async function loadTimes(){ async function loadTimes(){
const url = api('time'); const url = api('time');
const resp = await fetch(url,{credentials:'include'}); const resp = await fetch(url,{credentials:'include'});
@@ -88,8 +90,6 @@
async function update(time){ async function update(time){
const url = api(`time/${time.id}`); const url = api(`time/${time.id}`);
time.start_time = time.start_time.split(':').slice(0,2).join(':');
time.end_time = time.end_time.split(':').slice(0,2).join(':');
const res = await fetch(url,{ const res = await fetch(url,{
credentials:'include', credentials:'include',
method:'PATCH', method:'PATCH',
@@ -137,13 +137,13 @@
{#each sortedTimes as time,line} {#each sortedTimes as time,line}
<tr class={selected[time.id]?'selected':''}> <tr class={selected[time.id]?'selected':''}>
{#if timeMap.years[line]} {#if timeMap.years[line]}
<td class="year" rowspan={timeMap.years[line]} onclick={e => toggleRange(time.start_time.substring(0,4))}> <td class="year" rowspan={timeMap.years[line]} onclick={e => toggleRange(display(time.start_time).substring(0,4))}>
{time.start_time.substring(0,4)} {display(time.start_time).substring(0,4)}
</td> </td>
{/if} {/if}
{#if timeMap.months[line]} {#if timeMap.months[line]}
<td class="month" rowspan={timeMap.months[line]} onclick={e => toggleRange(time.start_time.substring(0,7))}> <td class="month" rowspan={timeMap.months[line]} onclick={e => toggleRange(display(time.start_time).substring(0,7))}>
{time.start_time.substring(5,7)} {display(time.start_time).substring(5,7)}
</td> </td>
{/if} {/if}
{#if detail == time.id} {#if detail == time.id}
@@ -152,7 +152,7 @@
</td> </td>
{:else} {:else}
<td class="start_end" onclick={e => toggleSelect(time.id)}> <td class="start_end" onclick={e => toggleSelect(time.id)}>
{time.start_time}{#if time.end_time}<wbr><wbr>{time.end_time}{/if} {display(time.start_time)}{#if time.end_time}<wbr><wbr>{display(time.end_time)}{/if}
</td> </td>
<td class="duration" onclick={e => {detail = time.id}}> <td class="duration" onclick={e => {detail = time.id}}>
{#if time.duration} {#if time.duration}

View File

@@ -0,0 +1,34 @@
export function display(timestamp){
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function padTo2Digits(num) {
return num.toString().padStart(2, '0');
}
export function msToTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
let timestring = '';
if (days) timestring += days.toString().padStart(2, '0') + ":";
if (days||hours) timestring += hours.toString().padStart(2, '0') + ":";
return timestring + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0');
}
export function now(){
return Math.floor(Date.now()/1000);
}
export function to_secs(datetime){
return Math.floor(new Date(datetime).getTime()/1000);
}

View File

@@ -152,14 +152,14 @@ CREATE TABLE IF NOT EXISTS {0} (
try { try {
if (track.id() == 0) { // create new if (track.id() == 0) { // create new
var rs = insertInto(TABLE_TIMES, USER_ID, SUBJECT, DESCRIPTION, START_TIME, END_TIME, STATE) var rs = insertInto(TABLE_TIMES, USER_ID, SUBJECT, DESCRIPTION, START_TIME, END_TIME, STATE)
.values(track.userId(), track.subject(), track.description(), track.startSecond(), track.endSecond(), track.state().code()) .values(track.userId(), track.subject(), track.description(), track.start(), track.end(), track.state().code())
.execute(db) .execute(db)
.getGeneratedKeys(); .getGeneratedKeys();
if (rs.next()) track.setId(rs.getLong(1)); if (rs.next()) track.setId(rs.getLong(1));
rs.close(); rs.close();
} else { // update } else { // update
replaceInto(TABLE_TIMES, ID, USER_ID, SUBJECT, DESCRIPTION, START_TIME, END_TIME, STATE) replaceInto(TABLE_TIMES, ID, USER_ID, SUBJECT, DESCRIPTION, START_TIME, END_TIME, STATE)
.values(track.id(), track.userId(), track.subject(), track.description(), track.startSecond(), track.endSecond(), track.state().code()) .values(track.id(), track.userId(), track.subject(), track.description(), track.start(), track.end(), track.state().code())
.execute(db).close(); .execute(db).close();
} }
var query = replaceInto(TABLE_TASK_TIMES,TIME_ID,TASK_ID); var query = replaceInto(TABLE_TASK_TIMES,TIME_ID,TASK_ID);

View File

@@ -21,25 +21,13 @@ import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.*; import de.srsoftware.umbrella.core.model.*;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*; import java.util.*;
public class TimeModule extends BaseHandler implements TimeService { public class TimeModule extends BaseHandler implements TimeService {
private class ExtendedTime extends Time{
private final Collection<Task> tasks;
public ExtendedTime(long id, long userId, String subject, String description, LocalDateTime start, LocalDateTime end, State state, Collection<Task> tasks) {
super(id, userId, subject, description, start, end, state, tasks.stream().map(Task::id).toList());
this.tasks = tasks;
}
}
private final TimeDb timeDb; private final TimeDb timeDb;
public TimeModule(ModuleRegistry registry, Configuration config) throws UmbrellaException { public TimeModule(ModuleRegistry registry, Configuration config) throws UmbrellaException {
super(registry); super(registry);
var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE)); var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE));
@@ -55,16 +43,9 @@ public class TimeModule extends BaseHandler implements TimeService {
if (user.isEmpty()) return unauthorized(ex); if (user.isEmpty()) return unauthorized(ex);
var head = path.pop(); var head = path.pop();
return switch (head) { return switch (head) {
case TRACK_TASK -> trackTask(user.get(),path,ex);
case STARTED -> getStartedTime(user.get(),ex); case STARTED -> getStartedTime(user.get(),ex);
case null -> getUserTimes(user.get(),ex); case null -> getUserTimes(user.get(),ex);
default -> { default -> super.doGet(path,ex);
try {
long timeId = Long.parseLong(head);
if (STOP.equals(path.pop())) yield getStoppedTask(user.get(),timeId,ex);
} catch (Exception ignored) {}
yield super.doGet(path,ex);
}
}; };
} catch (UmbrellaException e){ } catch (UmbrellaException e){
return send(ex,e); return send(ex,e);
@@ -72,8 +53,14 @@ public class TimeModule extends BaseHandler implements TimeService {
} }
private boolean getStoppedTask(UmbrellaUser user, long timeId, HttpExchange ex) throws IOException { private boolean getStoppedTask(UmbrellaUser user, long timeId, HttpExchange ex) throws IOException {
long now;
try {
now = Long.parseLong(body(ex));
} catch (NumberFormatException e) {
throw unprocessable("request body does not contain a timestamp!");
}
var time = timeDb.load(timeId); var time = timeDb.load(timeId);
timeDb.save(time.stop(LocalDateTime.now())); timeDb.save(time.stop(now));
return sendContent(ex,time); return sendContent(ex,time);
} }
@@ -104,7 +91,14 @@ public class TimeModule extends BaseHandler implements TimeService {
var head = path.pop(); var head = path.pop();
return switch (head) { return switch (head) {
case LIST -> listTimes(ex,user.get()); case LIST -> listTimes(ex,user.get());
default -> super.doPost(path,ex); case TRACK_TASK -> trackTask(user.get(),path,ex);
default -> {
try {
long timeId = Long.parseLong(head);
if (STOP.equals(path.pop())) yield getStoppedTask(user.get(),timeId,ex);
} catch (Exception ignored) {}
yield super.doGet(path,ex);
}
}; };
} catch (UmbrellaException e){ } catch (UmbrellaException e){
return send(ex,e); return send(ex,e);
@@ -136,7 +130,13 @@ public class TimeModule extends BaseHandler implements TimeService {
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
throw invalidFieldException(TASK_ID,"long value"); throw invalidFieldException(TASK_ID,"long value");
} }
var now = LocalDateTime.now().withNano(0);
long now;
try {
now = Long.parseLong(body(ex));
} catch (NumberFormatException e) {
throw unprocessable("request body does not contain a timestamp!");
}
var opt = getStartedTime(user); var opt = getStartedTime(user);
if (opt.isPresent()){ if (opt.isPresent()){
@@ -156,9 +156,7 @@ public class TimeModule extends BaseHandler implements TimeService {
private boolean getStartedTime(UmbrellaUser user, HttpExchange ex) throws IOException { private boolean getStartedTime(UmbrellaUser user, HttpExchange ex) throws IOException {
var startedTime = getStartedTime(user); var startedTime = getStartedTime(user);
if (startedTime.isPresent()){ if (startedTime.isPresent()) return sendContent(ex,startedTime.get());
return sendContent(ex,startedTime.get());
}
return send(ex,UmbrellaException.notFound("no started time")); return send(ex,UmbrellaException.notFound("no started time"));
} }