Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/dashboard/app/api/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ function parseMemberListQuery(request: Request): MemberListQuery {
search: searchParams.get("search") ?? undefined,
status: isMemberStatus(status) ? status : "all",
role: role && isMemberRole(role) ? role : "all",
joinedFrom: searchParams.get("joinedFrom") ?? undefined,
joinedTo: searchParams.get("joinedTo") ?? undefined,
limit: parseListLimit(searchParams.get("limit")),
page: parseListPage(searchParams.get("page")),
cursor: searchParams.get("cursor"),
Expand Down
96 changes: 90 additions & 6 deletions apps/dashboard/app/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ function readPageFilter(value: string | null): number {
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1;
}

function readDateFilter(value: string | null): string {
return value && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : "";
}

function MembersPageContent() {
const session = useSession();
const canWrite = canManageMembers(session, session.activeGuildId);
Expand All @@ -83,6 +87,8 @@ function MembersPageContent() {
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
const [status, setStatus] = useState<MemberStatusFilter>(() => readStatusFilter(searchParams.get("status")));
const [role, setRole] = useState<MemberRoleFilter>(() => readRoleFilter(searchParams.get("role")));
const [joinedFrom, setJoinedFrom] = useState(() => readDateFilter(searchParams.get("joinedFrom")));
const [joinedTo, setJoinedTo] = useState(() => readDateFilter(searchParams.get("joinedTo")));
const [page, setPage] = useState(() => readPageFilter(searchParams.get("page")));
const debouncedSearch = useDebouncedValue(search, 250);
const previousMembersRef = useRef<MockMember[]>(members);
Expand All @@ -93,8 +99,10 @@ function MembersPageContent() {
search: debouncedSearch.trim() || undefined,
status: status === "all" ? undefined : status,
role: role === "all" ? undefined : role,
joinedFrom: joinedFrom || undefined,
joinedTo: joinedTo || undefined,
}),
[debouncedSearch, guildId, page, role, status]
[debouncedSearch, guildId, joinedFrom, joinedTo, page, role, status]
);
const cacheRevision = useQueryInvalidation(membersQueryKey);

Expand All @@ -103,7 +111,15 @@ function MembersPageContent() {
const [form, setForm] = useState({ name: "", wallet: "" });

const updateFilterQuery = useCallback(
(updates: { search?: string; status?: MemberStatusFilter; role?: MemberRoleFilter; guild?: string; page?: number | null }) => {
(updates: {
search?: string;
status?: MemberStatusFilter;
role?: MemberRoleFilter;
joinedFrom?: string;
joinedTo?: string;
guild?: string;
page?: number | null;
}) => {
const next = new URLSearchParams(searchParams.toString());

if (updates.search !== undefined) {
Expand All @@ -128,6 +144,20 @@ function MembersPageContent() {
next.set("role", updates.role);
}
}
if (updates.joinedFrom !== undefined) {
if (updates.joinedFrom) {
next.set("joinedFrom", updates.joinedFrom);
} else {
next.delete("joinedFrom");
}
}
if (updates.joinedTo !== undefined) {
if (updates.joinedTo) {
next.set("joinedTo", updates.joinedTo);
} else {
next.delete("joinedTo");
}
}
if (updates.guild !== undefined) {
if (updates.guild) {
next.set("guild", updates.guild);
Expand Down Expand Up @@ -167,6 +197,18 @@ function MembersPageContent() {
updateFilterQuery({ role: value, page: null });
};

const handleJoinedFromChange = (value: string) => {
setJoinedFrom(value);
setPage(1);
updateFilterQuery({ joinedFrom: value, page: null });
};

const handleJoinedToChange = (value: string) => {
setJoinedTo(value);
setPage(1);
updateFilterQuery({ joinedTo: value, page: null });
};

const handleGuildFilterChange = (value: string) => {
setGuildId(value);
setPage(1);
Expand All @@ -183,20 +225,24 @@ function MembersPageContent() {
const nextSearch = searchParams.get("search") ?? "";
const nextStatus = readStatusFilter(searchParams.get("status"));
const nextRole = readRoleFilter(searchParams.get("role"));
const nextJoinedFrom = readDateFilter(searchParams.get("joinedFrom"));
const nextJoinedTo = readDateFilter(searchParams.get("joinedTo"));
const nextPage = readPageFilter(searchParams.get("page"));
const nextGuildId = searchParams.get("guild");

if (nextSearch !== search) setSearch(nextSearch);
if (nextStatus !== status) setStatus(nextStatus);
if (nextRole !== role) setRole(nextRole);
if (nextJoinedFrom !== joinedFrom) setJoinedFrom(nextJoinedFrom);
if (nextJoinedTo !== joinedTo) setJoinedTo(nextJoinedTo);
if (nextPage !== page) setPage(nextPage);
if (nextGuildId && nextGuildId !== guildId && guilds.some((candidate) => candidate.id === nextGuildId)) {
setGuildId(nextGuildId);
}
}, [guildId, guilds, page, role, search, searchParams, setGuildId, status]);
}, [guildId, guilds, joinedFrom, joinedTo, page, role, search, searchParams, setGuildId, status]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, role, status, guildId]);
}, [debouncedSearch, role, status, joinedFrom, joinedTo, guildId]);

// Drop previous tenant's rows immediately so the UI never shows stale data.
useEffect(() => {
Expand Down Expand Up @@ -224,6 +270,8 @@ function MembersPageContent() {
if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim());
if (status !== "all") params.set("status", status);
if (role !== "all") params.set("role", role);
if (joinedFrom) params.set("joinedFrom", joinedFrom);
if (joinedTo) params.set("joinedTo", joinedTo);

const data = await dashboardQueryCache.fetchQuery(membersQueryKey, async () => {
const res = await guildFetch(`/api/members?${params.toString()}`, guildId);
Expand Down Expand Up @@ -251,7 +299,7 @@ function MembersPageContent() {
return () => {
mounted = false;
};
}, [apiMode, cacheRevision, debouncedSearch, guildId, membersQueryKey, page, role, status]);
}, [apiMode, cacheRevision, debouncedSearch, guildId, joinedFrom, joinedTo, membersQueryKey, page, role, status]);

const updateMutation = useOptimisticMutation<MockMember, { id: string; data: Partial<MockMember> & { version?: number } }>({
mutationFn: async ({ id, data }) => {
Expand Down Expand Up @@ -468,6 +516,42 @@ function MembersPageContent() {
</button>
))}
</div>

{/* Join-date range row */}
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-sm text-slate-500">
Joined from
<input
type="date"
value={joinedFrom}
max={joinedTo || undefined}
onChange={(event) => handleJoinedFromChange(event.target.value)}
className="h-10 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-800 outline-none transition focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
/>
</label>
<label className="flex items-center gap-2 text-sm text-slate-500">
to
<input
type="date"
value={joinedTo}
min={joinedFrom || undefined}
onChange={(event) => handleJoinedToChange(event.target.value)}
className="h-10 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-800 outline-none transition focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
/>
</label>
{(joinedFrom || joinedTo) && (
<button
type="button"
onClick={() => {
handleJoinedFromChange("");
handleJoinedToChange("");
}}
className="text-sm font-medium text-violet-600 hover:text-violet-700"
>
Clear dates
</button>
)}
</div>
</div>
)}

Expand Down Expand Up @@ -569,7 +653,7 @@ function MembersPageContent() {

{members.length === 0 && (
<div className="mt-4">
<EmptyState title="No members match your filters" description="Adjust the search, guild, status, or role filter to see more members." icon="-" />
<EmptyState title="No members match your filters" description="Adjust the search, guild, status, role, or join date filters to see more members." icon="-" />
</div>
)}

Expand Down
22 changes: 21 additions & 1 deletion apps/dashboard/lib/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export function filterPasses(passes: Pass[], query: PassListQuery = {}): Pass[]

export function filterMembers(members: Member[], query: MemberListQuery = {}): Member[] {
const search = normaliseSearch(query.search);
const joinedFrom = parseDateBoundary(query.joinedFrom, "start");
const joinedTo = parseDateBoundary(query.joinedTo, "end");

return members.filter((member) => {
const matchesSearch =
!search ||
Expand All @@ -64,11 +67,28 @@ export function filterMembers(members: Member[], query: MemberListQuery = {}): M
!query.status || query.status === "all" || member.status === query.status;
const matchesRole =
!query.role || query.role === "all" || member.roles.includes(query.role);
const joinedAtTime = new Date(member.joinedAt).getTime();
const matchesJoinedFrom = joinedFrom === null || joinedAtTime >= joinedFrom;
const matchesJoinedTo = joinedTo === null || joinedAtTime <= joinedTo;

return matchesSearch && matchesStatus && matchesRole;
return matchesSearch && matchesStatus && matchesRole && matchesJoinedFrom && matchesJoinedTo;
});
}

/**
* Resolves a `YYYY-MM-DD` (or full ISO) date-range bound to an epoch ms
* timestamp. Date-only strings are widened to the start/end of that day so a
* `joinedTo` filter includes every member who joined on that calendar date,
* not just at midnight. Invalid/missing input yields `null` (no bound).
*/
export function parseDateBoundary(value: string | undefined, edge: "start" | "end"): number | null {
if (!value) return null;
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value);
const iso = isDateOnly ? `${value}T${edge === "start" ? "00:00:00.000" : "23:59:59.999"}Z` : value;
const time = new Date(iso).getTime();
return Number.isNaN(time) ? null : time;
}

export function parseListLimit(value: string | null): number | undefined {
if (!value) return undefined;
return clampPositiveInteger(Number(value), DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
Expand Down
20 changes: 20 additions & 0 deletions apps/dashboard/lib/repositories/adapters/durable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,26 @@ export class DurableMemberRepository
paramIdx++;
}

if (options.joinedFrom) {
const { parseDateBoundary } = await import("@/lib/pagination");
const from = parseDateBoundary(options.joinedFrom, "start");
if (from !== null) {
conditions.push(`joined_at >= $${paramIdx}`);
params.push(new Date(from).toISOString());
paramIdx++;
}
}

if (options.joinedTo) {
const { parseDateBoundary } = await import("@/lib/pagination");
const to = parseDateBoundary(options.joinedTo, "end");
if (to !== null) {
conditions.push(`joined_at <= $${paramIdx}`);
params.push(new Date(to).toISOString());
paramIdx++;
}
}

const where = conditions.join(" AND ");
const countResult = await query(
`SELECT COUNT(*)::integer as count FROM members WHERE ${where}`,
Expand Down
4 changes: 4 additions & 0 deletions apps/dashboard/lib/repositories/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export interface MemberListQuery extends PaginationOptions {
search?: string;
status?: Member["status"] | "all";
role?: string | "all";
/** Inclusive lower bound on `joinedAt`, as a `YYYY-MM-DD` date or ISO timestamp. */
joinedFrom?: string;
/** Inclusive upper bound on `joinedAt`, as a `YYYY-MM-DD` date or ISO timestamp. */
joinedTo?: string;
}

/**
Expand Down
Loading