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
15 changes: 15 additions & 0 deletions src/hooks/useTimelineUrlState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useSearch, useNavigate } from "@tanstack/react-router";
import type { TimelineSearch } from "@/lib/searchSchemas";
import type { VoteType } from "@/lib/voteConfig";

export type TimeFilter = TimelineSearch["time"];

Expand All @@ -16,6 +17,7 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") {
day: search.day,
time: search.time,
stages: search.stages,
votes: search.votes,
}),
structuralSharing: true,
});
Expand Down Expand Up @@ -54,6 +56,17 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") {
[navigate],
);

const updateVotes = useCallback(
(votes: VoteType[]) => {
navigate({
to: ".",
search: (prev) => ({ ...prev, votes }),
replace: true,
});
},
[navigate],
);

const clearFilters = useCallback(() => {
navigate({
to: ".",
Expand All @@ -66,9 +79,11 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") {
day: state.day,
time: state.time,
stages: state.stages,
votes: state.votes,
updateDay,
updateTime,
updateStages,
updateVotes,
clearFilters,
};
}
194 changes: 194 additions & 0 deletions src/lib/scheduleFilter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,200 @@ describe("filterScheduleDays", () => {
});
});

describe("vote-type predicate (my-vote chips)", () => {
it("keeps all sets when no vote types are selected", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [makeSet({ id: "set-1" }), makeSet({ id: "set-2" })],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({ voteTypes: [], userVotes: { "set-1": 2 } }),
TIMEZONE,
);

expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([
"set-1",
"set-2",
]);
});

it("keeps only sets matching a single selected vote type", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [makeSet({ id: "must-go-set" }), makeSet({ id: "interested-set" })],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({
voteTypes: ["mustGo"],
userVotes: { "must-go-set": 2, "interested-set": 1 },
}),
TIMEZONE,
);

expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([
"must-go-set",
]);
});

it("OR-s together a union of selected vote types", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [
makeSet({ id: "must-go-set" }),
makeSet({ id: "interested-set" }),
makeSet({ id: "wont-go-set" }),
],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({
voteTypes: ["mustGo", "interested"],
userVotes: {
"must-go-set": 2,
"interested-set": 1,
"wont-go-set": -1,
},
}),
TIMEZONE,
);

expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([
"must-go-set",
"interested-set",
]);
});

it("excludes a set with no vote when the filter is active", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [makeSet({ id: "unvoted-set" })],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({ voteTypes: ["mustGo"], userVotes: {} }),
TIMEZONE,
);

expect(result[0].stages[0].sets).toHaveLength(0);
});

it("is inert when userVotes is undefined (no viewer identity)", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [makeSet({ id: "set-1" }), makeSet({ id: "set-2" })],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({ voteTypes: ["mustGo"], userVotes: undefined }),
TIMEZONE,
);

expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([
"set-1",
"set-2",
]);
});

it("excludes a set whose vote value is unrecognized", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [makeSet({ id: "weird-vote-set" })],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({
voteTypes: ["mustGo"],
userVotes: { "weird-vote-set": 0 },
}),
TIMEZONE,
);

expect(result[0].stages[0].sets).toHaveLength(0);
});

it("excludes a set missing from the votes map entirely", () => {
const days = [
makeDay({
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 1,
sets: [makeSet({ id: "not-in-map" })],
},
],
}),
];

const result = filterScheduleDays(
days,
baseCriteria({
voteTypes: ["mustGo"],
userVotes: { "some-other-set": 2 },
}),
TIMEZONE,
);

expect(result[0].stages[0].sets).toHaveLength(0);
});
});

describe("combinations", () => {
it("applies day, time and stage predicates together", () => {
const days = [
Expand Down
39 changes: 34 additions & 5 deletions src/lib/scheduleFilter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getFestivalHour } from "@/lib/timeUtils";
import type { TimelineSearch } from "@/lib/searchSchemas";
import { getVoteConfig, type VoteType } from "@/lib/voteConfig";
import type {
ScheduleDay,
ScheduleSet,
Expand All @@ -15,9 +16,18 @@ export interface ScheduleFilterCriteria {
time: ScheduleTimeFilter;
// Stage ids to include; an empty array means all stages.
stages: string[];
// Future (PRD #188 - vote-type filtering): an optional field such as
// `voteTypes?: VoteType[]`, OR-ed against the viewer's own votes on each
// set, will slot in here without reshaping this interface.
// My-vote chips (PRD #188): selected vote types, OR-ed together. Empty or
// omitted turns the filter off (all sets kept, `userVotes` unconsulted).
// Always the viewer's own votes - group-vote scopes are out of scope here.
voteTypes?: VoteType[];
// The viewer's own votes, keyed by set id -> vote value (same shape as
// useUserVotes' return). Passed in, never fetched inside - keeps this
// function pure. The absent/empty distinction matters: `undefined` means
// there is no viewer identity (logged out), so vote filtering is INERT and
// every set passes even with `voteTypes` selected (a shared `?votes=` link
// must never dead-end a logged-out visitor); an empty object means a
// logged-in viewer with no votes, so an active filter excludes everything.
userVotes?: Record<string, number>;
}

function matchesTimeOfDay(
Expand All @@ -42,6 +52,23 @@ function matchesTimeOfDay(
}
}

function matchesVoteTypes(
set: ScheduleSet,
voteTypes: VoteType[] | undefined,
userVotes: Record<string, number> | undefined,
): boolean {
if (!voteTypes || voteTypes.length === 0) return true;
// No viewer identity (logged out): the vote filter is inert - see the
// absent-vs-empty note on ScheduleFilterCriteria.userVotes.
if (userVotes === undefined) return true;

const voteValue = userVotes[set.id];
if (voteValue === undefined) return false;

const voteType = getVoteConfig(voteValue);
return voteType !== undefined && voteTypes.includes(voteType);
}

// Applies the day / time-of-day / stage predicates shared by both Schedule
// views (Timeline and List). A day that doesn't match `criteria.day` is kept
// as an entry with empty stages (rather than dropped) purely to mirror the
Expand All @@ -65,8 +92,10 @@ export function filterScheduleDays(
)
.map((stage) => ({
...stage,
sets: stage.sets.filter((set) =>
matchesTimeOfDay(set, criteria.time, timezone),
sets: stage.sets.filter(
(set) =>
matchesTimeOfDay(set, criteria.time, timezone) &&
matchesVoteTypes(set, criteria.voteTypes, criteria.userVotes),
),
}));

Expand Down
34 changes: 34 additions & 0 deletions src/lib/searchSchemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { timelineSearchSchema } from "./searchSchemas";

describe("timelineSearchSchema", () => {
describe("votes (my-vote chips)", () => {
it("keeps valid vote types", () => {
const parsed = timelineSearchSchema.parse({
votes: ["mustGo", "interested", "wontGo"],
});

expect(parsed.votes).toEqual(["mustGo", "interested", "wontGo"]);
});

it("drops only the invalid entries, keeping valid ones", () => {
const parsed = timelineSearchSchema.parse({
votes: ["mustGo", "bogus"],
});

expect(parsed.votes).toEqual(["mustGo"]);
});

it("falls back to an empty selection when votes is not an array", () => {
const parsed = timelineSearchSchema.parse({ votes: "junk" });

expect(parsed.votes).toEqual([]);
});

it("defaults to an empty selection when votes is absent", () => {
const parsed = timelineSearchSchema.parse({});

expect(parsed.votes).toEqual([]);
});
});
});
12 changes: 12 additions & 0 deletions src/lib/searchSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { VOTES_TYPES, type VoteType } from "@/lib/voteConfig";

export const sortOptionSchema = z.enum([
"name-asc",
Expand Down Expand Up @@ -38,6 +39,16 @@ export const timelineSearchSchema = z.object({
day: z.string().catch("all"),
time: z.enum(["all", "morning", "afternoon", "evening"]).catch("all"),
stages: z.array(z.string()).catch([]),
// Viewer's own selected vote types, OR-ed; unknown entries drop one by
// one so a partially malformed shared link keeps its valid selections.
votes: z
.array(z.string())
.catch([])
.transform((votes) =>
votes.filter((vote): vote is VoteType =>
(VOTES_TYPES as readonly string[]).includes(vote),
),
),
scrollTo: z.string().optional().catch(undefined),
});

Expand All @@ -47,4 +58,5 @@ export const timelineSearchDefaults: TimelineSearch = {
day: "all",
time: "all",
stages: [],
votes: [],
};
Loading