Skip to content

Commit 5aeef70

Browse files
committed
Release new version with basic version of per-view filtering
2 parents cfc7ad0 + ed7387f commit 5aeef70

File tree

8 files changed

+466
-112
lines changed

8 files changed

+466
-112
lines changed

app/_shared.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ $orange-hue: 70deg;
99
$green-hue: 135deg;
1010
$project-hue: 300deg;
1111

12+
$include-filter-hue: 350deg;
13+
$exclude-filter-hue: 300deg;
14+
1215
$branding-color: lch(75, 30, $branding-hue);
1316

1417
@mixin badge-like($hue) {

app/app.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2521,3 +2521,139 @@ describe("performance", () => {
25212521
expect(t2 / t1).toBeLessThan(1.2);
25222522
});
25232523
});
2524+
2525+
describe("filter bar", () => {
2526+
function filterState(view: View, label: string) {
2527+
const filter = view.filterBar.filters.find((f) => f.label === label);
2528+
if (!filter) {
2529+
console.error("no such filter", view.filterBar);
2530+
throw "no such filter in filter bar";
2531+
}
2532+
return filter.state;
2533+
}
2534+
2535+
function setFilter(label: string, state: "include" | "exclude") {
2536+
return (view: View) => {
2537+
return [
2538+
{
2539+
tag: "filterBar" as const,
2540+
type: "set" as const,
2541+
id: view.filterBar.filters.find((f) => f.label === label)!.id,
2542+
state,
2543+
},
2544+
];
2545+
};
2546+
}
2547+
2548+
describe("paused filter", () => {
2549+
describe("is shown if and only if there are both paused and non-paused items", () => {
2550+
const step1 = updateAll(empty, [
2551+
...switchToFilter("not-done"),
2552+
...addTask("Task 0"),
2553+
...addTask("Task 1"),
2554+
dragToFilter(0, "paused"),
2555+
]);
2556+
2557+
function filterBarHas(view: View, label: string) {
2558+
return view.filterBar.filters.map((f) => f.label).includes(label);
2559+
}
2560+
2561+
test("no paused items", () => {
2562+
const step2 = updateAll(step1, [dragToFilter(0, "done")]);
2563+
expect(filterBarHas(view(step2), "Paused")).toBe(false);
2564+
});
2565+
2566+
test("only paused items", () => {
2567+
const step2 = updateAll(step1, [dragToFilter(1, "done")]);
2568+
expect(filterBarHas(view(step2), "Paused")).toBe(false);
2569+
});
2570+
2571+
test("paused and non-paused items", () => {
2572+
expect(filterBarHas(view(step1), "Paused")).toBe(true);
2573+
});
2574+
});
2575+
2576+
describe("can be toggled on or off", () => {
2577+
const step1 = updateAll(empty, [
2578+
...switchToFilter("not-done"),
2579+
...addTask("Task 0"),
2580+
...addTask("Task 1"),
2581+
dragToFilter(0, "paused"),
2582+
]);
2583+
2584+
const step2 = updateAll(step1, [setFilter("Paused", "include")]);
2585+
2586+
const step3 = updateAll(step2, [setFilter("Paused", "exclude")]);
2587+
2588+
const step4 = updateAll(step3, [setFilter("Paused", "exclude")]);
2589+
2590+
test("the filter is neutral by default", () => {
2591+
expect(filterState(view(step1), "Paused")).toBe("neutral");
2592+
});
2593+
2594+
test("the filter can be toggled on", () => {
2595+
expect(filterState(view(step2), "Paused")).toBe("include");
2596+
});
2597+
2598+
test("the filter can be toggled off", () => {
2599+
expect(filterState(view(step3), "Paused")).toBe("exclude");
2600+
});
2601+
2602+
test("the filter can be disabled again", () => {
2603+
expect(filterState(view(step4), "Paused")).toBe("neutral");
2604+
});
2605+
});
2606+
2607+
describe("hides or shows paused tasks, depending on state", () => {
2608+
const step1 = updateAll(empty, [
2609+
...switchToFilter("all"),
2610+
...addTask("Not paused parent 0"),
2611+
...addTask("Paused parent 1"),
2612+
...addTask("Paused 2"),
2613+
...addTask("Not paused 3"),
2614+
...addTask("Paused top-level 4"),
2615+
...addTask("Not paused top-level 5"),
2616+
...dragAndDropNth(1, 0, {side: "below", indentation: 1}),
2617+
...dragAndDropNth(2, 1, {side: "below", indentation: 2}),
2618+
...dragAndDropNth(3, 2, {side: "below", indentation: 2}),
2619+
dragToFilter(2, "paused"),
2620+
dragToFilter(4, "paused"),
2621+
]);
2622+
2623+
const step2 = updateAll(step1, [setFilter("Paused", "include")]);
2624+
2625+
const step3 = updateAll(step2, [setFilter("Paused", "exclude")]);
2626+
2627+
const step4 = updateAll(step3, [setFilter("Paused", "exclude")]);
2628+
2629+
test("when set to 'include', only paused subtasks and their parents are shown", () => {
2630+
expect(tasks(step2, ["title", "indentation"])).toEqual([
2631+
{title: "Not paused parent 0", indentation: 0},
2632+
{title: "Paused parent 1", indentation: 1},
2633+
{title: "Paused 2", indentation: 2},
2634+
{title: "Paused top-level 4", indentation: 0},
2635+
]);
2636+
});
2637+
2638+
test("when set to 'exclude', only non-paused subtasks and their parents are shown", () => {
2639+
expect(tasks(step3, ["title", "indentation"])).toEqual([
2640+
{title: "Not paused parent 0", indentation: 0},
2641+
{title: "Paused parent 1", indentation: 1},
2642+
{title: "Not paused 3", indentation: 2},
2643+
{title: "Not paused top-level 5", indentation: 0},
2644+
]);
2645+
});
2646+
2647+
test("when set to 'neutral', all tasks are shown", () => {
2648+
expect(tasks(step4, ["title", "indentation"])).toEqual([
2649+
{title: "Not paused parent 0", indentation: 0},
2650+
{title: "Paused parent 1", indentation: 1},
2651+
{title: "Paused 2", indentation: 2},
2652+
{title: "Not paused 3", indentation: 2},
2653+
{title: "Paused top-level 4", indentation: 0},
2654+
{title: "Not paused top-level 5", indentation: 0},
2655+
]);
2656+
});
2657+
});
2658+
});
2659+
});

app/app.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@ export type SelectEditingTask = {tag: "selectEditingTask"; id: string};
1919
export type DragId = {type: "task"; id: string};
2020
export type DropId = {type: "filter"; id: FilterId} | {type: "list"; target: Tasks.DropTargetHandle};
2121

22+
export type FilterBarEvent = {tag: "filterBar"; type: "set"; id: string; state: "include" | "exclude"};
23+
2224
export type Event =
2325
| CheckEvent
2426
| TextFieldEvent<TextFieldId>
2527
| SelectEditingTask
2628
| SelectFilterEvent
2729
| TaskEditor.Event
2830
| Drag.DragEvent<DragId, DropId>
29-
| Storage.Event;
31+
| Storage.Event
32+
| FilterBarEvent;
3033

3134
export type Effect =
3235
| {type: "fileDownload"; name: string; contents: string}
@@ -41,6 +44,7 @@ export type State = {
4144
textFields: TextFieldStates<TextFieldId>;
4245
editor: TaskEditor.State;
4346
taskDrag: Drag.DragState<DragId, DropId>;
47+
subtaskFilters: Tasks.SubtaskFilters;
4448
};
4549

4650
export const empty: State = {
@@ -49,6 +53,7 @@ export const empty: State = {
4953
editor: TaskEditor.empty,
5054
filter: "ready",
5155
taskDrag: {dragging: null, hovering: null},
56+
subtaskFilters: [],
5257
};
5358

5459
export type FilterIndicator = null | {text: string; color: "red" | "orange" | "green"} | {};
@@ -65,24 +70,48 @@ export type SideBarSectionView = {title: string; filter: FilterId; filters: Filt
6570

6671
export type FileControlsView = "saveLoad" | null;
6772

73+
export type FilterBarView = {
74+
filters: {id: string; label: string; state: "neutral" | "include" | "exclude"}[];
75+
};
76+
6877
export type View = {
6978
fileControls: FileControlsView;
7079
addTask: {value: string};
7180
sideBar: SideBarSectionView[];
7281
taskList: TaskListView;
82+
filterBar: FilterBarView;
7383
editor: TaskEditor.View;
7484
};
7585

86+
function viewFilterBar(app: State, args: {today: Date}): FilterBarView {
87+
const list = Tasks.view({...app, today: args.today});
88+
89+
const anyPaused = list.some((r) => r.rows.some((t) => t.type === "task" && t.paused));
90+
const anyUnpaused = list.some((r) => r.rows.some((t) => t.type === "task" && !t.paused));
91+
92+
function filterState(id: string): "neutral" | "include" | "exclude" {
93+
const filter = app.subtaskFilters.find((f) => f.id === id);
94+
if (filter === undefined) {
95+
return "neutral";
96+
}
97+
return filter.state;
98+
}
99+
100+
if (app.subtaskFilters.find((f) => f.id === "paused") || (anyPaused && anyUnpaused))
101+
return {filters: [{id: "paused", label: "Paused", state: filterState("paused")}]};
102+
else return {filters: []};
103+
}
104+
76105
export function view(app: State, args: {today: Date}): View {
77-
const activeProjects = Tasks.activeProjects(app.tasks);
106+
const activeProjects = Tasks.activeProjects({...app, ...args});
78107

79108
function filterView(
80109
filter: FilterId,
81110
opts?: {counter: "small" | "red" | "orange" | "green"; count?: number},
82111
): FilterView {
83112
function indicator() {
84113
if (!opts?.counter) return null;
85-
const count = opts.count ?? Tasks.count(app.tasks, filter, args);
114+
const count = opts.count ?? Tasks.count({...app, ...args}, filter);
86115
if (count === 0) return null;
87116
if (opts.counter === "small") return {};
88117
return {text: count.toString(), color: opts.counter};
@@ -91,7 +120,7 @@ export function view(app: State, args: {today: Date}): View {
91120
return {
92121
label: Tasks.filterTitle(app.tasks, filter),
93122
filter,
94-
selected: Tasks.isSubfilter(app.tasks, app.filter, filter),
123+
selected: Tasks.isSubfilter({...app, ...args}, app.filter, filter),
95124
dropTarget: {type: "filter", id: filter},
96125
indicator: indicator(),
97126
};
@@ -128,6 +157,7 @@ export function view(app: State, args: {today: Date}): View {
128157
filters: [filterView("archive")],
129158
},
130159
],
160+
filterBar: viewFilterBar(app, args),
131161
taskList: Tasks.view({...app, today: args.today}),
132162
editor: TaskEditor.view(app.editor),
133163
};
@@ -169,10 +199,10 @@ export function updateApp(app: State, ev: Event, args: {today: Date}): State {
169199
const [drag, drop] = dropped_;
170200

171201
if (drop.type === "filter") {
172-
const app_ = {...app, tasks: edit(app, drag.id, [{type: "moveToFilter", filter: drop.id}], args)};
202+
const app_ = {...app, tasks: edit({...app, ...args}, drag.id, [{type: "moveToFilter", filter: drop.id}])};
173203
return {...app_, editor: TaskEditor.reload(app_)};
174204
} else if (drop.type === "list") {
175-
return {...app, tasks: edit(app, drag.id, [{type: "move", target: drop.target}], args)};
205+
return {...app, tasks: edit({...app, ...args}, drag.id, [{type: "move", target: drop.target}])};
176206
} else {
177207
const unreachable: never = drop;
178208
return unreachable;
@@ -189,26 +219,46 @@ export function updateApp(app: State, ev: Event, args: {today: Date}): State {
189219
return {...app, filter: ev.filter};
190220
}
191221

222+
function handleFilterBar(app: State, ev: Event) {
223+
if (ev.tag !== "filterBar") return app;
224+
225+
let subtaskFilters = app.subtaskFilters;
226+
const currentState = subtaskFilters.find((f) => f.id === ev.id);
227+
if (currentState === undefined) {
228+
if (ev.id === "paused") subtaskFilters = [...subtaskFilters, {id: ev.id, state: ev.state}];
229+
else console.error("Invalid filter ID", ev);
230+
} else {
231+
subtaskFilters = subtaskFilters.flatMap((f) =>
232+
f.id === ev.id ? (f.state === ev.state ? [] : [{...f, state: ev.state}]) : [f],
233+
);
234+
}
235+
236+
return {
237+
...app,
238+
subtaskFilters,
239+
};
240+
}
241+
192242
function handleTextField(app: State, ev: Event) {
193243
if (ev.tag !== "textField") return app;
194244
const result = {...app, textFields: updateTextFields(app.textFields, ev)};
195245
if (ev.type === "submit") {
196-
return {...result, tasks: add(app, {title: textFieldValue(app.textFields, "addTitle")}, args)};
246+
return {...result, tasks: add({...app, ...args}, {title: textFieldValue(app.textFields, "addTitle")})};
197247
} else {
198248
return result;
199249
}
200250
}
201251

202252
function handleEdit(app: State, ev: Event) {
203253
if (ev.tag !== "editor") return app;
204-
const tasks = edit(app, ev.component.id.taskId, TaskEditor.editOperationsFor(app.editor, ev), args);
254+
const tasks = edit({...app, ...args}, ev.component.id.taskId, TaskEditor.editOperationsFor(app.editor, ev));
205255
return {...app, editor: TaskEditor.load({tasks}, app.editor!.id), tasks};
206256
}
207257

208258
function handleCheck(app: State, ev: Event) {
209259
if (ev.tag !== "check") return app;
210260
const value = Tasks.find(app.tasks, ev.id)?.status === "done" ? "active" : "done";
211-
const tasks = edit(app, ev.id, [{type: "set", property: "status", value}], args);
261+
const tasks = edit({...app, ...args}, ev.id, [{type: "set", property: "status", value}]);
212262
return {...app, tasks, editor: TaskEditor.reload({...app, tasks})};
213263
}
214264

@@ -233,6 +283,7 @@ export function updateApp(app: State, ev: Event, args: {today: Date}): State {
233283
(app) => handleCheck(app, ev),
234284
(app) => handleEdit(app, ev),
235285
(app) => handleSelectFilter(app, ev),
286+
(app) => handleFilterBar(app, ev),
236287
(app) => handleSelectEditingTask(app, ev),
237288
(app) => handleDrop(app, ev),
238289
(app) => handleDragState(app, ev),

0 commit comments

Comments
 (0)