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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ jobs:
paths: .features-gen/e2e/features/mytasks
- name: search
paths: .features-gen/e2e/features/search
- name: labels
paths: .features-gen/e2e/features/labels
steps:
- uses: actions/checkout@v4

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ iOS and Android.
| **Tasks** | Title, notes, priority (Low→High), due dates with overdue/today/upcoming cues | ✅ |
| **Subtasks** | Break a task into a checklist with its own done-count | ✅ |
| **Comments** | Discuss and log activity on any task | ✅ |
| **Labels** | Reusable colored tags; apply on task detail, chips render on every card | ✅ |
| **My Tasks** | Everything open across all projects, grouped by due date | ✅ |
| **Search** | Live substring search across every task's title and notes | ✅ |
| **Complete** | One-tap complete on the board and in lists, with a strike-through state | ✅ |
Expand Down
1 change: 1 addition & 0 deletions amplify/seed/clearAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export async function clearAll(): Promise<void> {
await clearOneModel(client.models.Section);
await clearOneModel(client.models.Project);
await clearOneModel(client.models.Label);
// (Label cleared above — kept explicit so a new model isn't silently missed.)
}
11 changes: 11 additions & 0 deletions amplify/seed/fixtures/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ export interface SeedTask {
dueOffsetDays?: number;
notes?: string;
subtasks?: string[];
/** Names of labels (from seedLabels) to apply to this task. */
labels?: string[];
}

/** Reusable label registry — name + color key (a --tf-proj-* palette key). */
export const seedLabels: { name: string; color: string }[] = [
{ name: 'Marketing', color: 'rose' },
{ name: 'Design', color: 'violet' },
{ name: 'Urgent', color: 'amber' },
{ name: 'Backend', color: 'sky' },
];

export interface SeedProject {
name: string;
color: string;
Expand All @@ -33,6 +43,7 @@ export const seedProjects: SeedProject[] = [
dueOffsetDays: -2,
notes: 'Blog post + email + social.',
subtasks: ['Outline key points', 'Write first draft'],
labels: ['Marketing', 'Urgent'],
},
{ title: 'Design hero banner', section: 'To do', priority: 'MEDIUM', dueOffsetDays: 3 },
{ title: 'Set up analytics', section: 'In progress', priority: 'LOW', dueOffsetDays: 1 },
Expand Down
18 changes: 18 additions & 0 deletions amplify/seed/seedLabels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Seeds the reusable label registry, returning a name→id map so tasks can be
* tagged by label name in the workspace fixture. */
import { client, OWNER_WRITE } from './seedClient';
import { seedLabels } from './fixtures/workspace';

export async function seedLabelData(): Promise<Map<string, string>> {
const byName = new Map<string, string>();
for (const label of seedLabels) {
const { data, errors } = await client.models.Label.create(
{ name: label.name, color: label.color },
OWNER_WRITE,
);
if (errors || !data) throw new Error(`Label ${label.name}: ${JSON.stringify(errors)}`);
byName.set(label.name, data.id);
}
console.log(`Seeded ${byName.size} labels.`);
return byName;
}
6 changes: 5 additions & 1 deletion amplify/seed/seedWorkspace.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the seed client (which otherwise reads amplify_outputs.json + .env on import).
const { createProject, createSection, createTask } = vi.hoisted(() => ({
const { createProject, createSection, createTask, createLabel } = vi.hoisted(() => ({
createProject: vi.fn(),
createSection: vi.fn(),
createTask: vi.fn(),
createLabel: vi.fn(),
}));
vi.mock('./seedClient', () => ({
client: {
models: {
Project: { create: createProject },
Section: { create: createSection },
Task: { create: createTask },
Label: { create: createLabel },
},
},
OWNER_WRITE: { authMode: 'userPool' },
Expand All @@ -24,9 +26,11 @@ beforeEach(() => {
createProject.mockReset();
createSection.mockReset();
createTask.mockReset();
createLabel.mockReset();
createProject.mockResolvedValue({ data: { id: 'proj' }, errors: null });
createSection.mockResolvedValue({ data: { id: 'sec' }, errors: null });
createTask.mockResolvedValue({ data: { id: 'task' }, errors: null });
createLabel.mockResolvedValue({ data: { id: 'lbl' }, errors: null });
});

describe('seedWorkspaceData', () => {
Expand Down
11 changes: 10 additions & 1 deletion amplify/seed/seedWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** Seeds the demo workspace (projects → sections → tasks + subtasks). */
import { client, OWNER_WRITE } from './seedClient';
import { seedLabelData } from './seedLabels';
import { seedProjects, type SeedTask } from './fixtures/workspace';

/** Resolve a day offset (from today) to a YYYY-MM-DD date string. */
Expand All @@ -9,12 +10,18 @@ function offsetDate(days: number): string {
return d.toISOString().slice(0, 10);
}

/** Map a task's label names to ids via the seeded registry (unknown dropped). */
function labelIdsFor(task: SeedTask, labelMap: Map<string, string>): string[] {
return (task.labels ?? []).map((n) => labelMap.get(n)).filter((x): x is string => !!x);
}

/** Create one task (+ its subtasks) in a section. */
async function createTaskWithSubtasks(
projectId: string,
sectionId: string,
task: SeedTask,
order: number,
labelMap: Map<string, string>,
): Promise<void> {
const { data: created, errors } = await client.models.Task.create(
{
Expand All @@ -26,6 +33,7 @@ async function createTaskWithSubtasks(
priority: task.priority,
dueDate: task.dueOffsetDays === undefined ? undefined : offsetDate(task.dueOffsetDays),
sortOrder: order,
labelIds: labelIdsFor(task, labelMap),
},
OWNER_WRITE,
);
Expand All @@ -48,6 +56,7 @@ async function createTaskWithSubtasks(

/** Create every seed project with its sections + tasks. Returns project count. */
export async function seedWorkspaceData(): Promise<number> {
const labelMap = await seedLabelData();
for (let p = 0; p < seedProjects.length; p++) {
const proj = seedProjects[p];
const { data: project, errors } = await client.models.Project.create(
Expand Down Expand Up @@ -75,7 +84,7 @@ export async function seedWorkspaceData(): Promise<number> {
for (let t = 0; t < proj.tasks.length; t++) {
const task = proj.tasks[t];
const sectionId = sectionIds.get(task.section);
if (sectionId) await createTaskWithSubtasks(project.id, sectionId, task, t);
if (sectionId) await createTaskWithSubtasks(project.id, sectionId, task, t, labelMap);
}
}
console.log(`Seeded ${seedProjects.length} projects.`);
Expand Down
19 changes: 19 additions & 0 deletions e2e/features/labels/labels.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Feature: Task labels
As a signed-in user
I want to tag tasks with reusable colored labels
So that I can categorize and scan my work (Asana-style tags)

# Honest e2e: the seeded task carries seeded labels — assert the chip renders on
# the board, then apply another label on the detail and see it stick.

Scenario: A seeded label shows as a chip on the board
Given a signed-in user
And the user opens the "Product Launch" project
Then a label chip "Marketing" is visible on the board

Scenario: Applying a label on the task detail persists
Given a signed-in user
And the user opens the "Product Launch" project
When the user opens the task titled "Design hero banner"
And the user applies the "Design" label
Then the "Design" label is shown as applied
22 changes: 22 additions & 0 deletions e2e/steps/labels.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';

const { When, Then } = createBdd();

Then('a label chip {string} is visible on the board', async ({ page }, name: string) => {
await expect(page.getByTestId('label-chip').filter({ hasText: name }).first()).toBeVisible({
timeout: 15_000,
});
});

When('the user applies the {string} label', async ({ page }, name: string) => {
await page.getByTestId('label-option').filter({ hasText: name }).first().click();
});

Then('the {string} label is shown as applied', async ({ page }, name: string) => {
await expect(page.getByTestId('label-option').filter({ hasText: name }).first()).toHaveAttribute(
'aria-pressed',
'true',
{ timeout: 15_000 },
);
});
6 changes: 5 additions & 1 deletion src/features/board/BoardColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { TaskCard } from '../task/TaskCard';
import { AddCard } from './AddCard';
import { nowISO } from '../task/today';
import { resolveLabels } from '../labels/resolveLabels';
import type { Column } from './taskGrouping';
import type { TaskRecord } from '../../lib/dataClient';
import type { LabelRecord, TaskRecord } from '../../lib/dataClient';

/** One board column: a section header, its task cards, and an inline add-card
* composer. New cards append after the current highest sortOrder. Renders +
* delegates the two mutations up to the board hook. */
export function BoardColumn({
column,
labels = [],
onAddTask,
onToggleDone,
}: {
column: Column;
labels?: LabelRecord[];
onAddTask: (input: { sectionId: string; title: string; order: number }) => void;
onToggleDone: (input: { id: string; done: boolean; now: string }) => void;
}) {
Expand All @@ -28,6 +31,7 @@ export function BoardColumn({
<TaskCard
key={task.id}
task={task}
labels={resolveLabels(task.labelIds, labels)}
onToggleDone={(t) =>
onToggleDone({ id: t.id, done: t.status !== 'DONE', now: nowISO() })
}
Expand Down
9 changes: 7 additions & 2 deletions src/features/board/BoardContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ import { BoardColumn } from './BoardColumn';
import { ListSection } from './ListSection';
import type { Column } from './taskGrouping';
import type { ViewMode } from './viewMode';
import type { LabelRecord } from '../../lib/dataClient';

type AddTask = (input: { sectionId: string; title: string; order: number }) => void;
type ToggleDone = (input: { id: string; done: boolean; now: string }) => void;

/** Renders the project's sections either as horizontal board columns or as a
* vertical list of collapsible sections, per the chosen view mode. Shared by
* ProjectView so the screen stays a thin shell. */
* vertical list of collapsible sections, per the chosen view mode. Passes the
* label registry down so cards can render their chips. Shared by ProjectView. */
export function BoardContent({
mode,
columns,
labels = [],
onAddTask,
onToggleDone,
}: {
mode: ViewMode;
columns: Column[];
labels?: LabelRecord[];
onAddTask: AddTask;
onToggleDone: ToggleDone;
}) {
Expand All @@ -27,6 +30,7 @@ export function BoardContent({
<ListSection
key={column.section.id}
column={column}
labels={labels}
defaultOpen={i === 0 || column.tasks.length > 0}
onAddTask={onAddTask}
onToggleDone={onToggleDone}
Expand All @@ -41,6 +45,7 @@ export function BoardContent({
<BoardColumn
key={column.section.id}
column={column}
labels={labels}
onAddTask={onAddTask}
onToggleDone={onToggleDone}
/>
Expand Down
5 changes: 5 additions & 0 deletions src/features/board/ListSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ import { chevronDown, chevronForward } from 'ionicons/icons';
import { TaskCard } from '../task/TaskCard';
import { AddCard } from './AddCard';
import { nowISO } from '../task/today';
import { resolveLabels } from '../labels/resolveLabels';
import type { Column } from './taskGrouping';
import type { LabelRecord } from '../../lib/dataClient';

/** One section in the List view: a collapsible header (name + count) over a
* stacked list of task rows, with an inline add at the bottom. Same data +
* mutations as a board column, laid out as a vertical list. */
export function ListSection({
column,
labels = [],
defaultOpen = true,
onAddTask,
onToggleDone,
}: {
column: Column;
labels?: LabelRecord[];
defaultOpen?: boolean;
onAddTask: (input: { sectionId: string; title: string; order: number }) => void;
onToggleDone: (input: { id: string; done: boolean; now: string }) => void;
Expand Down Expand Up @@ -43,6 +47,7 @@ export function ListSection({
<TaskCard
key={task.id}
task={task}
labels={resolveLabels(task.labelIds, labels)}
onToggleDone={(t) =>
onToggleDone({ id: t.id, done: t.status !== 'DONE', now: nowISO() })
}
Expand Down
3 changes: 2 additions & 1 deletion src/features/board/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import './board.css';
export function ProjectView() {
const { id } = useParams<{ id: string }>();
const project = useProject(id);
const { query, columns, addTask, toggleDone } = useBoard(id);
const { query, columns, addTask, toggleDone, labels } = useBoard(id);
const { mode, choose } = useViewMode(id, project.data?.view as ViewMode | undefined);
useDocumentTitle(project.data?.name ?? 'Project');

Expand All @@ -50,6 +50,7 @@ export function ProjectView() {
<BoardContent
mode={mode}
columns={columns}
labels={labels}
onAddTask={(input) => addTask.mutate(input)}
onToggleDone={(input) => toggleDone.mutate(input)}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/features/board/useBoard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchBoard, ensureDefaultSections } from './boardApi';
import { groupTasksBySection } from './taskGrouping';
import { createTask, setTaskDone } from '../task/tasksApi';
import { useLabels } from '../labels/useLabels';

/** Board data for a project: loads sections + tasks, ensures default columns
* exist, and exposes them grouped into columns plus the task mutations the
Expand Down Expand Up @@ -40,5 +41,7 @@ export function useBoard(projectId: string) {
onSuccess: invalidate,
});

return { query, columns, addTask, toggleDone };
const labels = useLabels().query.data ?? [];

return { query, columns, addTask, toggleDone, labels };
}
21 changes: 21 additions & 0 deletions src/features/labels/LabelChips.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LabelChips } from './LabelChips';
import type { LabelRecord } from '../../lib/dataClient';

describe('LabelChips', () => {
it('renders a chip per label', () => {
const labels = [
{ id: 'a', name: 'Marketing', color: 'rose' } as LabelRecord,
{ id: 'b', name: 'Urgent', color: 'amber' } as LabelRecord,
];
render(<LabelChips labels={labels} />);
expect(screen.getAllByTestId('label-chip')).toHaveLength(2);
expect(screen.getByText('Marketing')).toBeInTheDocument();
});

it('renders nothing when empty', () => {
const { container } = render(<LabelChips labels={[]} />);
expect(container.firstChild).toBeNull();
});
});
26 changes: 26 additions & 0 deletions src/features/labels/LabelChips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { projectColorVar } from '../projects/projectColors';
import type { LabelRecord } from '../../lib/dataClient';
import './labels.css';

/** A row of colored label chips. Presentational; the caller resolves which
* labels to show (resolveLabels). Renders nothing when empty. */
export function LabelChips({ labels }: { labels: LabelRecord[] }) {
if (labels.length === 0) return null;
return (
<span className="label-chips" data-testid="label-chips">
{labels.map((label) => (
<span
key={label.id}
className="label-chip"
data-testid="label-chip"
style={{
color: projectColorVar(label.color),
borderColor: projectColorVar(label.color),
}}
>
{label.name}
</span>
))}
</span>
);
}
Loading
Loading