feat(activity): per-viewer archive and hide

Two new per-viewer flags on activities, mirroring the heart/done shape:

- ARCHIVE: any viewer (incl. the owner) can archive a row. Out of
  sight by default but the permalink still resolves. For owners,
  archive also filters the row out of their /<bruker>/liste public
  page — "I'm done sharing this."
- HIDE: only non-owners. "This doesn't appeal to me." Endpoint
  returns 400 cannot_hide_own when the owner tries it (they should
  delete or archive instead).

Both default-filtered from GET /api/activities. Opt-in via query
params with three modes each: exclude (default), include (1), only.
?archived=1&hidden=1 includes both; ?archived=only shows just the
archive. The list endpoint composes the SQL filters per-viewer
(anonymous viewers have no rows in either table → no-ops).

Schema: two new tables user_archived_activities and
user_hidden_activities, both composite-PK on (user_id, activity_id),
cascade on both refs. Same shape as the existing engagement tables.

Types: viewer_archived + viewer_hidden on every Activity variant.
viewer_hidden on private is always false (the endpoint refuses);
docstring on the type explains why.

Bulk lookups extended so the list endpoint stays at constant queries.
Single-row paths get per-row viewerArchived/viewerHidden helpers.
fetchRowForViewer keeps preserving the viewer's custom sort_position
on toggles.

UI: two new buttons in ActivityRow's action row — "📦 Arkiver" /
"📦 Arkivert" (everyone) and "🙈 Gjem" / "🙈 Skjult" (non-owners
only). Two new checkboxes near the search field on /hjem: "📦 Vis
arkivert" and "🙈 Vis skjult". Toggling either re-fetches from the
server because the filtering is server-side. When an archive/hide
flips the row out of the current view, Home.svelte triggers a
refetch so the row disappears in real time.

Public-list endpoint (/api/users/:bruker/list) also filters out the
owner's own archived rows — consistent with "archive means filed
away from active view."

Tests: 3 new in engagement.test.ts — viewer archives + per-viewer
isolation, owner's archive filters their public list, hide refuses
on own row + ?hidden=only path. Suite goes 102 → 105.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 20:19:44 +02:00
commit 6e005fc2d7
8 changed files with 418 additions and 7 deletions

View file

@ -179,6 +179,50 @@
}
}
// --- Archive / Hide -----------------------------------------------------
// Two per-viewer flags. Archive applies to anyone (including the owner);
// hide applies only to non-owners. Both opt the row out of the default
// listing — the user has to toggle "Vis arkiv" / "Vis skjulte" to see
// them again.
let archiveBusy = $state(false);
let hideBusy = $state(false);
async function toggleArchive() {
if (!session.user || archiveBusy) return;
archiveBusy = true;
const wasArchived = view.viewer_archived;
localOverride = { ...view, viewer_archived: !wasArchived };
try {
const updated = wasArchived
? await api.unarchiveActivity(view.id)
: await api.archiveActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
localOverride = null;
} finally {
archiveBusy = false;
}
}
async function toggleHide() {
if (!session.user || hideBusy) return;
hideBusy = true;
const wasHidden = view.viewer_hidden;
localOverride = { ...view, viewer_hidden: !wasHidden };
try {
const updated = wasHidden
? await api.unhideActivity(view.id)
: await api.hideActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
localOverride = null;
} finally {
hideBusy = false;
}
}
// --- Bookmarks -----------------------------------------------------------
let bookmarkBusy = $state(false);
async function toggleBookmark() {
@ -374,6 +418,29 @@
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}
{#if session.user}
<button
type="button"
onclick={toggleArchive}
disabled={archiveBusy}
aria-pressed={view.viewer_archived}
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
>
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
</button>
{/if}
{#if session.user && !isOwner}
<!-- Hide is non-owner only — owners can delete or archive instead. -->
<button
type="button"
onclick={toggleHide}
disabled={hideBusy}
aria-pressed={view.viewer_hidden}
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
>
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
</button>
{/if}
{#if canDelete}
<button class="danger" type="button" onclick={del}>Slett</button>
{/if}

View file

@ -22,12 +22,28 @@
let error: string | null = $state(null);
let query = $state('');
onMount(load);
// Two toggles control what shape of list we ask the server for:
// - default: active rows only (excludes archived AND hidden)
// - showArchived: include archived rows mixed in with active
// - showHidden: include hidden rows mixed in
// The user can flip these independently. Anonymous (publicOnly) viewers
// never see the toggles since they have no archive/hide state.
let showArchived = $state(false);
let showHidden = $state(false);
onMount(() => load());
async function load() {
loading = true;
try {
activities = await api.listActivities();
activities = await api.listActivities(
publicOnly
? undefined
: {
...(showArchived ? { archived: '1' as const } : {}),
...(showHidden ? { hidden: '1' as const } : {}),
},
);
} catch (e) {
error = 'Kunne ikke laste oppføringer.';
} finally {
@ -35,6 +51,14 @@
}
}
// Re-fetch from the server when the toggles flip — the WHERE clause is
// server-side so we can't filter the existing array client-side.
$effect(() => {
void showArchived;
void showHidden;
if (!loading) load();
});
function onCreated(a: Activity) {
activities = [a, ...activities];
showForm = false;
@ -46,7 +70,13 @@
}
function onChanged(a: Activity) {
// When a row's archived/hidden state flips, it may no longer belong in
// the current view (toggles are off → archived/hidden rows disappear).
// Reload to get the authoritative list from the server.
const needsRefetch =
(a.viewer_archived && !showArchived) || (a.viewer_hidden && !showHidden);
activities = activities.map((x) => (x.id === a.id ? a : x));
if (needsRefetch) load();
}
function onDeleted(id: string) {
@ -201,6 +231,19 @@
aria-label="Søk i aktiviteter"
/>
{#if !publicOnly && session.user}
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" bind:checked={showArchived} />
<span class="muted">📦 Vis arkivert</span>
</label>
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" bind:checked={showHidden} />
<span class="muted">🙈 Vis skjult</span>
</label>
</div>
{/if}
{#if showForm}
<div style="margin-top: 1rem;">
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />

View file

@ -64,7 +64,13 @@ export const api = {
}),
// --- activities -----------------------------------------------------------
listActivities: () => http<Activity[]>('/activities'),
listActivities: (opts?: { archived?: '1' | 'only'; hidden?: '1' | 'only' }) => {
const qs = new URLSearchParams();
if (opts?.archived) qs.set('archived', opts.archived);
if (opts?.hidden) qs.set('hidden', opts.hidden);
const q = qs.toString();
return http<Activity[]>(`/activities${q ? `?${q}` : ''}`);
},
getActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
@ -83,6 +89,14 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
undoneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
archiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
unarchiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
hideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
unhideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>