200 lines
4.7 KiB
Svelte
200 lines
4.7 KiB
Svelte
<script lang="ts">
|
||
import { getPresenceSessions } from "#lib/api/endpoints.js";
|
||
import type { PresenceSession } from "#lib/api/types.js";
|
||
import Button from "#lib/components/ui/Button.svelte";
|
||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
|
||
import { formatSpan } from "#lib/format/presence.js";
|
||
import { t } from "#lib/i18n/locale.svelte.js";
|
||
|
||
interface Props {
|
||
days: number;
|
||
first: PresenceSession[];
|
||
pageSize: number;
|
||
peerId: number;
|
||
tz: string;
|
||
}
|
||
|
||
const { days, first, pageSize, peerId, tz }: Props = $props();
|
||
|
||
const MS_IN_DAY = 86_400_000;
|
||
|
||
let extra = $state<PresenceSession[]>([]);
|
||
let loading = $state(false);
|
||
let failed = $state(false);
|
||
let done = $state(false);
|
||
|
||
const sessions = $derived([...first, ...extra]);
|
||
const exhausted = $derived(done || first.length < pageSize);
|
||
const groups = $derived.by(() => {
|
||
const result: { label: string; sessions: PresenceSession[] }[] = [];
|
||
for (const session of sessions) {
|
||
const label = dayLabel(new Date(session.started));
|
||
const last = result.at(-1);
|
||
if (last?.label === label) {
|
||
last.sessions.push(session);
|
||
} else {
|
||
result.push({ label, sessions: [session] });
|
||
}
|
||
}
|
||
return result;
|
||
});
|
||
|
||
function dayLabel(date: Date): string {
|
||
const today = new Date();
|
||
if (date.toDateString() === today.toDateString()) {
|
||
return t("presence.today");
|
||
}
|
||
if (
|
||
date.toDateString() ===
|
||
new Date(today.getTime() - MS_IN_DAY).toDateString()
|
||
) {
|
||
return t("presence.yesterday");
|
||
}
|
||
const text = formatLongDay(date);
|
||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||
}
|
||
|
||
function span(from: string, to: string): string {
|
||
return from === to ? from : `${from}–${to}`;
|
||
}
|
||
|
||
async function loadMore() {
|
||
if (loading) {
|
||
return;
|
||
}
|
||
loading = true;
|
||
failed = false;
|
||
try {
|
||
const batch = await getPresenceSessions(peerId, days, tz, {
|
||
limit: pageSize,
|
||
offset: sessions.length,
|
||
});
|
||
const seen = new Set(sessions.map((session) => session.started));
|
||
extra = [
|
||
...extra,
|
||
...batch.filter((session) => !seen.has(session.started)),
|
||
];
|
||
done = batch.length < pageSize;
|
||
} catch {
|
||
failed = true;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
{#if sessions.length === 0}
|
||
<p class="state">{t("presence.noSessions")}</p>
|
||
{:else}
|
||
{#each groups as group (group.label)}
|
||
<h4 class="day">{group.label}</h4>
|
||
<ul>
|
||
{#each group.sessions as session (session.started)}
|
||
<li>
|
||
<span>
|
||
{session.ongoing
|
||
? t("presence.since", { time: formatTime(session.started) })
|
||
: span(formatTime(session.started), formatTime(session.ended))}
|
||
</span>
|
||
<span class={["length", { online: session.ongoing }]}>
|
||
{session.ongoing
|
||
? t("presence.online")
|
||
: formatSpan(session.seconds)}
|
||
</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/each}
|
||
{#if loading}
|
||
<div class="state"><Spinner /></div>
|
||
{:else if failed}
|
||
<div class="state">
|
||
<p>{t("presence.sessionsFailed")}</p>
|
||
<Button onclick={loadMore} smaller variant="text">{t("ui.retry")}</Button>
|
||
</div>
|
||
{:else if !exhausted}
|
||
<button class="more" onclick={loadMore} type="button">
|
||
{t("ui.showMore")}
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
|
||
<style lang="scss">
|
||
.day {
|
||
margin: 0;
|
||
padding: 0.625rem 1rem 0.25rem;
|
||
|
||
font-size: 0.8125rem;
|
||
font-weight: var(--font-weight-normal);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
ul {
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
|
||
li {
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
|
||
min-height: 2.5rem;
|
||
padding: 0 1rem;
|
||
|
||
font-size: 0.9375rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.length {
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-secondary);
|
||
|
||
&.online {
|
||
color: var(--color-primary);
|
||
}
|
||
}
|
||
|
||
.state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
align-items: center;
|
||
|
||
margin: 0;
|
||
padding: 1.5rem 1rem;
|
||
|
||
font-size: 0.9375rem;
|
||
color: var(--color-text-secondary);
|
||
text-align: center;
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.more {
|
||
cursor: pointer;
|
||
|
||
width: calc(100% - 1rem);
|
||
margin: 0.5rem;
|
||
padding: 0.5rem;
|
||
border: 0;
|
||
border-radius: 0.5rem;
|
||
|
||
font: inherit;
|
||
font-size: 0.875rem;
|
||
color: var(--color-primary);
|
||
|
||
background-color: var(--color-background-secondary);
|
||
|
||
&:focus-visible {
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: 1px;
|
||
}
|
||
}
|
||
</style>
|