126 lines
3.1 KiB
Svelte
126 lines
3.1 KiB
Svelte
<script>
|
||
import { fmtDuration, fmtTime, roundTs } from '../lib/time.js';
|
||
|
||
let { entries = [], rounding = 15, now = Date.now(), onselect = null } = $props();
|
||
|
||
const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq));
|
||
|
||
/**
|
||
* Durations are measured between the *displayed* boundaries, so a row can never
|
||
* read "10:45–11:45" next to "1h02". The running entry is the exception: its end
|
||
* is not shown as a time, so it keeps a live, unrounded count.
|
||
*/
|
||
function duration(entry) {
|
||
if (entry.endTs == null) return Math.max(0, now - entry.startTs);
|
||
return Math.max(
|
||
0,
|
||
roundTs(entry.endTs, rounding) - roundTs(entry.startTs, rounding),
|
||
);
|
||
}
|
||
</script>
|
||
|
||
{#if ordered.length}
|
||
<ul class="timeline">
|
||
{#each ordered as entry (entry.id)}
|
||
<li>
|
||
<!-- The whole row is the target: correcting a mis-tapped task should not
|
||
require hunting for a small pencil. -->
|
||
<button
|
||
type="button"
|
||
class:running={entry.endTs == null}
|
||
onclick={() => onselect?.(entry)}
|
||
disabled={!onselect}
|
||
>
|
||
<span class="time tnum">
|
||
{fmtTime(entry.startTs, rounding)}–{entry.endTs == null
|
||
? '…'
|
||
: fmtTime(entry.endTs, rounding)}
|
||
</span>
|
||
<span class="dur tnum">{fmtDuration(duration(entry))}</span>
|
||
<span class="what">
|
||
<strong>{entry.kind === 'break' && entry.task !== 'Pause' ? `Pause · ${entry.task}` : entry.task}</strong>
|
||
{#if entry.automatic}<span class="dim">Auto</span>{/if}
|
||
{#if entry.client}<span class="dim">{entry.client}</span>{/if}
|
||
</span>
|
||
{#if onselect}<span class="edit" aria-hidden="true">✎</span>{/if}
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
<style>
|
||
.timeline {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
li + li {
|
||
border-top: 2px solid var(--line);
|
||
}
|
||
|
||
/* Times and totals on one line, the task name on its own full-width line
|
||
below: at phone width a shared row squeezes long names into mid-word
|
||
breaks. */
|
||
li button {
|
||
width: 100%;
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
grid-template-areas:
|
||
'time dur edit'
|
||
'what what edit';
|
||
align-items: center;
|
||
column-gap: 10px;
|
||
row-gap: 2px;
|
||
padding: 12px 4px;
|
||
border: 0;
|
||
border-radius: 0;
|
||
text-align: left;
|
||
font-weight: 400;
|
||
min-height: var(--tap);
|
||
}
|
||
|
||
li button[disabled] {
|
||
opacity: 1;
|
||
}
|
||
|
||
li button.running {
|
||
font-weight: 800;
|
||
}
|
||
|
||
.time {
|
||
grid-area: time;
|
||
font-size: 0.95rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.dur {
|
||
grid-area: dur;
|
||
justify-self: end;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.edit {
|
||
grid-area: edit;
|
||
padding-left: 6px;
|
||
color: var(--fg-dim);
|
||
}
|
||
|
||
.what {
|
||
grid-area: what;
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* Let the column shrink inside the grid so long names wrap rather than
|
||
overflow — a truncated task name is useless at a glance. */
|
||
min-width: 0;
|
||
overflow-wrap: break-word;
|
||
}
|
||
|
||
.what span {
|
||
font-size: 0.85rem;
|
||
}
|
||
</style>
|