Skip to content

Commit

Permalink
fix: properly count the number of items
Browse files Browse the repository at this point in the history
  • Loading branch information
cecilia-sanare committed Jan 31, 2024
1 parent 56be8ef commit 4cd62aa
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 33 deletions.
56 changes: 56 additions & 0 deletions src/components/List.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Button } from '@/components/ui/button';
import { ListStatus, getStatus, getStatusCount } from '@/lib/items';
import { cn } from '@/lib/utils';
import { Storage, StorageKeys, Todo } from '@/storage';
import { CheckCircle, CircleDot, Flame, ListTodo, XCircle } from 'lucide-react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';

export type ListProps = {
list: Todo.List;
onDelete: () => void;
};

const LIST_ICONS: Record<ListStatus, React.ElementType> = {
[ListStatus.NOT_STARTED]: () => <XCircle className="text-red-700" />,
[ListStatus.IN_PROGRESS]: () => <CircleDot className="text-yellow-500" />,
[ListStatus.DONE]: () => <CheckCircle className="text-green-700" />,
};

export function List({ list, onDelete }: ListProps) {
const { done, total } = useMemo(() => getStatusCount(list.items), [list.items]);
const status = getStatus({ done, total });
const Icon = LIST_ICONS[status];

return (
<div className="flex gap-2">
<Button className="flex flex-1 justify-start gap-2 overflow-hidden" asChild variant="secondary">
<Link to={`/todo/${list.id}`}>
<ListTodo />
<div className={cn('truncate', status === ListStatus.DONE && 'line-through')}>{list.label}</div>
</Link>
</Button>
<div className="hidden md:flex gap-2 bg-secondary h-10 px-4 items-center justify-center rounded-md">
<div>
{done} / {total}
</div>
<Icon />
</div>
<ConfirmDialog
description="This action cannot be undone. This will permanently delete this list."
onSubmit={onDelete}
>
<Button className="shrink-0" variant="destructive" size="icon">
<Flame />
</Button>
</ConfirmDialog>
</div>
);
}

export namespace TodoListsPage {
export async function loader() {
return await Storage.get(StorageKeys.LISTS);
}
}
121 changes: 120 additions & 1 deletion src/lib/__tests__/items.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Todo } from '@/storage';
import { describe, expect, it } from 'vitest';
import { isDone, setDone } from '../items';
import { getStatusCount, isDone, setDone } from '../items';

describe('Item Utils', () => {
describe('fn(isDone)', () => {
Expand Down Expand Up @@ -114,4 +114,123 @@ describe('Item Utils', () => {
]);
});
});

describe('fn(getStatusCount)', () => {
it('should support a simple item being done', () => {
expect(
getStatusCount({
id: '',
label: '',
done: true,
subItems: [],
})
).toEqual({
done: 1,
total: 1,
});
});

it('should support a simple item not being done', () => {
expect(
getStatusCount({
id: '',
label: '',
done: false,
subItems: [],
})
).toEqual({
done: 0,
total: 1,
});
});

it('should support nested items', () => {
expect(
getStatusCount({
id: '',
label: '',
done: true,
subItems: [
{
id: '',
label: '',
done: true,
subItems: [],
},
{
id: '',
label: '',
done: true,
subItems: [],
},
],
})
).toEqual({
done: 2,
total: 2,
});
});

it('should support partially completed nested items', () => {
expect(
getStatusCount({
id: '',
label: '',
done: false,
subItems: [
{
id: '',
label: '',
done: false,
subItems: [],
},
{
id: '',
label: '',
done: true,
subItems: [],
},
],
})
).toEqual({
done: 1,
total: 2,
});
});

it('should support complex lists', () => {
expect(
getStatusCount([
{
id: '',
label: '',
done: true,
subItems: [
{
id: '',
label: '',
done: true,
subItems: [],
},
{
id: '',
label: '',
done: true,
subItems: [],
},
],
},
{
id: '',
label: '',
done: true,
subItems: [],
},
])
).toEqual({
done: 3,
total: 3,
});
});
});
});
61 changes: 61 additions & 0 deletions src/lib/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,64 @@ export function hasChanged(updatedItem: Todo.Item, originalItem?: Todo.Item): bo
export function hasNotChanged(updatedItem: Todo.Item, originalItem?: Todo.Item): boolean {
return !hasChanged(updatedItem, originalItem);
}

export type StatusCountResult = {
done: number;
total: number;
};

// TODO(Refactor): Likely a way cleaner way to do this that my brain just isn't coming up with at the moment
export function getStatusCount(items: Todo.Item | Todo.Item[]): StatusCountResult {
if (items === undefined)
return {
done: 0,
total: 0,
};

if (Array.isArray(items)) {
return items.reduce(
(output, item) => {
const count = getStatusCount(item);

return {
done: output.done + count.done,
total: output.total + count.total,
};
},
{
done: 0,
total: 0,
}
);
}

if (items.subItems.length > 0) {
return getStatusCount(items.subItems);
}

if (!items.done) {
return {
done: 0,
total: 1,
};
}

return {
done: 1,
total: 1,
};
}

export enum ListStatus {
NOT_STARTED,
IN_PROGRESS,
DONE,
}

export function getStatus({ done, total }: StatusCountResult): ListStatus {
if (done === 0) return ListStatus.NOT_STARTED;

if (done === total) return ListStatus.DONE;

return ListStatus.IN_PROGRESS;
}
45 changes: 13 additions & 32 deletions src/routes/TodoListsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { List } from '@/components/List';
import { PageContent } from '@/components/PageContent';
import { PageHeader } from '@/components/PageHeader';
import { Button } from '@/components/ui/button';
import { Storage, StorageKeys, Todo } from '@/storage';
import { createId } from '@paralleldrive/cuid2';
import { useCachedState } from '@rain-cafe/react-utils';
import { BadgePlus, Flame, ListTodo } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useLoaderData } from 'react-router-dom';
import { BadgePlus } from 'lucide-react';
import { useLoaderData } from 'react-router-dom';

export function TodoListsPage() {
const externalList = useLoaderData() as Todo.List[];
const [search, setSearch] = useState<string>('');
const [lists, setLists] = useCachedState<Todo.List[]>(() => externalList, [externalList]);
const filteredLists = useMemo(() => {
return search ? lists?.filter((list) => list.label.includes(search)) : lists;
}, [lists, search]);

if (!lists || !filteredLists) return null;
if (!lists) return null;

return (
<>
Expand Down Expand Up @@ -49,29 +44,15 @@ export function TodoListsPage() {
</div>
) : (
<>
{filteredLists.map(({ id, label, items }) => (
<div className="flex gap-2" key={id}>
<Button className="flex flex-1 justify-start gap-2 overflow-hidden" asChild variant="secondary">
<Link to={`/todo/${id}`}>
<ListTodo />
<div className="truncate">{label}</div>
</Link>
</Button>
<div className="hidden md:flex bg-secondary h-10 px-4 items-center justify-center rounded-md">
{items.length} Items
</div>
<ConfirmDialog
description="This action cannot be undone. This will permanently delete this list."
onSubmit={async () => {
setLists(lists.filter((list) => list.id !== id));
await Storage.delete(StorageKeys.LISTS, id);
}}
>
<Button className="shrink-0" variant="destructive" size="icon">
<Flame />
</Button>
</ConfirmDialog>
</div>
{lists.map((list) => (
<List
key={list.id}
list={list}
onDelete={async () => {
setLists(lists.filter(({ id }) => id !== list.id));
await Storage.delete(StorageKeys.LISTS, list.id);
}}
/>
))}
</>
)}
Expand Down

0 comments on commit 4cd62aa

Please sign in to comment.