Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions frontend/src/lib/components/AssigneeCombobox.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { searchUsers } from "$lib/api/users";
import type { UserBrief } from "$lib/types/api";
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";

let {
value = $bindable<number | null>(null),
Expand All @@ -20,35 +21,33 @@
let loading = $state(false);
let selectedName = $state("");
let timer: ReturnType<typeof setTimeout> | null = null;
let requestSeq = 0;
const requestGuard = createLatestRequestGuard();

$effect(() => {
if (initialName) selectedName = initialName;
});

function doSearch(q: string) {
const seq = ++requestSeq;
if (!q.trim()) {
requestGuard.invalidate();
results = [];
open = false;
loading = false;
return;
}
loading = true;
searchUsers(q, 10)
.then((r) => {
if (seq !== requestSeq) return;
runLatest(requestGuard, () => searchUsers(q, 10), {
onSuccess: (r) => {
results = r.items;
open = true;
})
.catch(() => {
if (seq !== requestSeq) return;
},
onError: () => {
results = [];
})
.finally(() => {
if (seq !== requestSeq) return;
},
onFinally: () => {
loading = false;
});
},
});
}

function handleInput(e: Event) {
Expand All @@ -59,7 +58,7 @@
}

function select(user: UserBrief) {
requestSeq++;
requestGuard.invalidate();
value = user.id;
selectedName = user.name;
query = "";
Expand All @@ -68,7 +67,7 @@
}

function clear() {
requestSeq++;
requestGuard.invalidate();
value = null;
selectedName = "";
query = "";
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/components/TaskFilters.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
if (!mounted) return;
const value = draft;
const timer = setTimeout(() => {
untrack(() => onSearchChange?.(value));
untrack(() => {
if (value !== searchQuery) {
onSearchChange?.(value);
}
});
}, 250);
return () => clearTimeout(timer);
});
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/lib/latest-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export function createLatestRequestGuard() {
let version = 0;

return {
next(): number {
return ++version;
},
isLatest(token: number): boolean {
return token === version;
},
invalidate(): void {
version++;
},
};
}

export type LatestRequestGuard = ReturnType<typeof createLatestRequestGuard>;

type RunLatestHandlers<T> = {
onSuccess: (value: T) => void;
onError?: (error: unknown) => void;
onFinally?: () => void;
};

export function runLatest<T>(
guard: LatestRequestGuard,
work: () => Promise<T>,
handlers: RunLatestHandlers<T>,
): void {
const token = guard.next();
work()
.then((value) => {
if (!guard.isLatest(token)) return;
handlers.onSuccess(value);
})
.catch((error) => {
if (!guard.isLatest(token)) return;
handlers.onError?.(error);
})
.finally(() => {
if (!guard.isLatest(token)) return;
handlers.onFinally?.();
});
}
28 changes: 19 additions & 9 deletions frontend/src/lib/pages/admin-projects.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import SearchIcon from "@lucide/svelte/icons/search";
import XIcon from "@lucide/svelte/icons/x";
import type { Project } from "$lib/types/api";
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";

let projects = $state<Project[]>([]);
let loading = $state(true);
Expand All @@ -33,6 +34,8 @@
let editRepoUrl = $state("");
let editSaving = $state(false);

const requestGuard = createLatestRequestGuard();

function handleSearchInput(value: string) {
searchTerm = value;
if (searchTimeout) clearTimeout(searchTimeout);
Expand All @@ -57,17 +60,24 @@
return new Date(dateStr).toLocaleDateString();
}

async function load() {
function load() {
loading = true;
error = "";
try {
const res = await listProjects(100, 0, debouncedSearch || undefined);
projects = res.items;
} catch (e: any) {
error = e?.message || "Failed to load projects";
} finally {
loading = false;
}
runLatest(
requestGuard,
() => listProjects(100, 0, debouncedSearch || undefined),
{
onSuccess: (res) => {
projects = res.items;
},
onError: (e) => {
error = (e as Error)?.message || "Failed to load projects";
},
onFinally: () => {
loading = false;
},
},
);
}

function openCreate() {
Expand Down
43 changes: 27 additions & 16 deletions frontend/src/lib/pages/admin-users.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import * as Badge from "$lib/components/ui/badge";
import * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types/api";
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";

const PAGE_SIZE = 20;

Expand All @@ -29,6 +30,8 @@

let activatingId = $state<number | null>(null);

const requestGuard = createLatestRequestGuard();

let totalPages = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE)));

const statusBadgeColors: Record<string, string> = {
Expand All @@ -50,24 +53,32 @@
return new Date(dateStr).toLocaleDateString();
}

async function loadUsers() {
function loadUsers() {
loading = true;
error = "";
try {
const res = await listUsers({
status: filterStatus || undefined,
role: filterRole || undefined,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
});
users = res.items;
total = res.total;
} catch (e: any) {
error = e?.message || "Failed to load users";
users = [];
} finally {
loading = false;
}
runLatest(
requestGuard,
() =>
listUsers({
status: filterStatus || undefined,
role: filterRole || undefined,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),
{
onSuccess: (res) => {
users = res.items;
total = res.total;
},
onError: (e) => {
error = (e as Error)?.message || "Failed to load users";
users = [];
},
onFinally: () => {
loading = false;
},
},
);
}

function onFilterChange() {
Expand Down
45 changes: 27 additions & 18 deletions frontend/src/lib/pages/dashboard-personal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import * as Card from "$lib/components/ui/card";
import type { Task, Project } from "$lib/types/api";
import { ACTIVE_STATUSES } from "$lib/types/api";
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";

let { params = {} }: { params?: Record<string, string> } = $props();

Expand All @@ -25,6 +26,8 @@
let searchQuery = $state("");
let sort = $state("-created_at");

const requestGuard = createLatestRequestGuard();

function toggleStatus(s: string) {
if (filterStatuses.includes(s)) {
filterStatuses = filterStatuses.filter((x) => x !== s);
Expand Down Expand Up @@ -73,24 +76,30 @@
filters.priorities = filterPriorities.join(",");
if (searchQuery) filters.search = searchQuery;

Promise.all([
listProjects(100, 0).then((r) => {
projects = r.items;
}),
getMyTasks({ limit: 50, ...filters }).then((res) => {
const currentUserId = getUser()?.id;
createdTasks = res.items.filter((t) => t.author.id === currentUserId);
assignedTasks = res.items.filter(
(t) => t.assignee?.id === currentUserId,
);
}),
])
.catch((e) => {
error = e.message || "Failed to load tasks";
})
.finally(() => {
loading = false;
});
runLatest(
requestGuard,
() =>
Promise.all([
listProjects(100, 0).then((r) => r.items),
getMyTasks({ limit: 50, ...filters }).then((res) => res.items),
]),
{
onSuccess: ([projectItems, taskItems]) => {
projects = projectItems;
const currentUserId = getUser()?.id;
createdTasks = taskItems.filter((t) => t.author.id === currentUserId);
assignedTasks = taskItems.filter(
(t) => t.assignee?.id === currentUserId,
);
},
onError: (e) => {
error = (e as Error).message || "Failed to load tasks";
},
onFinally: () => {
loading = false;
},
},
);
});
</script>

Expand Down
39 changes: 24 additions & 15 deletions frontend/src/lib/pages/dashboard-tasks.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { Button } from "$lib/components/ui/button";
import type { Project, Task } from "$lib/types/api";
import { ACTIVE_STATUSES } from "$lib/types/api";
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";

let { params = {} }: { params?: Record<string, string> } = $props();

Expand All @@ -28,6 +29,8 @@
let offset = $state(0);
const limit = 50;

const requestGuard = createLatestRequestGuard();

function toggleStatus(s: string) {
if (filterStatuses.includes(s)) {
filterStatuses = filterStatuses.filter((x) => x !== s);
Expand Down Expand Up @@ -71,21 +74,27 @@
filters.priorities = filterPriorities.join(",");
if (searchQuery) filters.search = searchQuery;

Promise.all([
listProjects(100, 0).then((r) => {
projects = r.items;
}),
listTasks(filters).then((r) => {
tasks = r.items;
total = r.total;
}),
])
.catch((e) => {
error = e.message || "Failed to load tasks";
})
.finally(() => {
loading = false;
});
runLatest(
requestGuard,
() =>
Promise.all([
listProjects(100, 0).then((r) => r.items),
listTasks(filters).then((r) => ({ items: r.items, total: r.total })),
]),
{
onSuccess: ([projectItems, taskRes]) => {
projects = projectItems;
tasks = taskRes.items;
total = taskRes.total;
},
onError: (e) => {
error = (e as Error).message || "Failed to load tasks";
},
onFinally: () => {
loading = false;
},
},
);
}

function resetFilters() {
Expand Down
Loading
Loading