Household Task Management That Actually Gets Used
Most task management systems fail at home because they're designed for work. I built one around how families actually assign, track, and complete household tasks.
The Whiteboard Problem
We tried whiteboards. We tried shared notes. We tried shouting reminders across the kitchen. None of it worked reliably, because the gap between "someone mentions a task" and "the task is captured with enough context to act on" is where most household systems fail.
"Fix the bathroom door" sounds clear until three days later when nobody remembers which bathroom, what's wrong with the door, or who was supposed to handle it. Household tasks need structure, but they can't require a project management certification to enter.
Tasks in Notion, Managed by AI
The task system is part of a larger home management app called Quartermaster. Tasks live in a Notion database alongside a linked Projects database. The schema is defined with Zod:
// src/task/schemas/task.schema.ts
export const TaskSchema = z.object({
id: z.object({ number: z.number(), prefix: z.string().nullable() }),
name: z.string(),
status: TaskStatusEnum.nullable(),
priority: z.string().nullable(),
dueDate: z
.object({ start: z.string(), end: z.string().nullable() })
.nullable(),
project: z.array(z.string()),
assignee: z.array(z.object({ id: z.string(), name: z.string().optional() })),
});
export type Task = z.infer<typeof TaskSchema>;
export const TaskPropertyMap = {
id: 'ID',
name: 'Name',
status: 'Status',
priority: 'Priority',
dueDate: 'Due Date',
project: 'Project',
assignee: 'Assignee',
} as const;The interesting part is how tasks get created. You can use the API directly, but the more common path is AI-powered parsing. Say or type something like "fix the leaky kitchen faucet by Friday, high priority, it's part of the plumbing project" and the system extracts the structured fields automatically.
How AI Parsing Works
The parser uses Claude with tool use. When raw text comes in, Claude receives the text along with the ability to look up existing projects. It calls a tool to get the project list, then returns structured JSON:
// src/claude/claude.service.ts
async parseTask(
rawText: string,
projects: { id: string; name: string }[],
): Promise<ParsedTaskInput> {
const tools: Anthropic.Messages.Tool[] = [
{
name: 'list_projects',
description: 'List all projects from the Notion project database.',
input_schema: {
type: 'object' as const,
properties: {},
required: [],
},
},
];
const systemPrompt = `You are a task parser. Given raw text, extract a
concise task name and determine which project it belongs to.
Use the list_projects tool to see available projects, then respond with JSON:
{
"name": "concise task name",
"project": "exact project name or null",
"priority": "exact priority or null",
"dueDate": "YYYY-MM-DD or null",
"status": "exact status or null"
}`;
const messages: Anthropic.Messages.MessageParam[] = [
{ role: 'user', content: `Parse this into a task: "${rawText}"` },
];
let response = await this.client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 500,
system: systemPrompt,
tools,
messages,
});
// Handle tool use loop
while (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter(
(b): b is Anthropic.Messages.ToolUseBlock => b.type === 'tool_use',
);
const toolResults: Anthropic.Messages.ToolResultBlockParam[] =
toolUseBlocks.map((block) => ({
type: 'tool_result' as const,
tool_use_id: block.id,
content: JSON.stringify(projects),
}));
messages.push(
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults },
);
response = await this.client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 500,
system: systemPrompt,
tools,
messages,
});
}
const parsed = JSON.parse(textBlock?.text.trim() ?? '{}');
const result: ParsedTaskInput = { name: parsed.name ?? rawText };
if (parsed.project) result.project = parsed.project;
if (parsed.priority) result.priority = parsed.priority;
if (parsed.dueDate) result.dueDate = parsed.dueDate;
if (parsed.status) result.status = parsed.status as TaskStatus;
return result;
}The project matching is the subtle part. You don't need to remember the exact project name — "plumbing stuff" will match to your "Home Plumbing Repairs" project. Claude fetches the project list and picks the closest match, or flags when nothing fits.
The controller wires it all together — the parse flag triggers the AI path:
// src/task/task.controller.ts
@Post()
async createTask(@Body() body: {
text?: string;
parse?: boolean;
name?: string;
status?: TaskStatus;
priority?: string;
dueDate?: string;
project?: string[];
}) {
if (body.parse && body.text) {
const projects = await this.taskService.listProjects();
const projectList = projects.map((p) => ({
id: p.pageId,
name: p.name,
}));
const parsed = await this.claudeService.parseTask(body.text, projectList);
const projectIds: string[] = [];
if (parsed.project) {
const matched = await this.claudeService.matchProject(
parsed.project,
projectList,
);
if (matched) projectIds.push(matched.id);
}
return this.taskService.createTask({
name: parsed.name,
status: parsed.status ?? undefined,
priority: parsed.priority ?? undefined,
dueDate: parsed.dueDate
? { start: parsed.dueDate, end: null }
: undefined,
project: projectIds.length ? projectIds : undefined,
});
}
// Direct creation path (no AI parsing)
return this.taskService.createTask({ ... });
}This means task capture can happen through any channel that hits the API: a web form, a voice assistant, an MCP-connected AI chat. The entry friction drops to near zero.
Making Tasks Physical
Here's where it gets unconventional. Digital task lists have a visibility problem — they're trapped behind a screen. When you're moving through your house doing chores, you're not checking an app every five minutes.
Quartermaster can print tasks on a thermal label printer. The printing method resolves the project relation, formats the due date relative to today, and sends it all through a label template:
// src/task/task.service.ts
async printTask(
task: Task & { pageId: string },
options?: { assignTo?: string; qr?: boolean },
): Promise<void> {
const { name } = task;
const projectName = await this.resolveProjectName(task);
const { assignTo, qr } = options || {};
const assignedTo =
assignTo ??
task.assignee.map((a) => (a.name ?? a.id).split(' ')[0]).join(', ');
const raw = task.dueDate?.start;
let dueDateFormatted: string = '';
if (raw) {
const m = moment(raw);
if (m.hour() === 0 && m.minute() === 0) m.add(1, 'day');
dueDateFormatted = `${moment().to(m)} (${m.format('YYYY-MM-DD')})`;
}
const template = structuredClone(projectTaskTemplate);
const filteredTemplate = {
...template,
sections: template.sections.filter(
(s) => s.type !== 'qrcode' || qr !== false,
),
};
await this.printerService.printLabel(filteredTemplate, {
assignedTo,
id: String(task.id?.number ?? ''),
name,
projectName,
status: task.status ?? '',
priority: task.priority ?? '',
dueDateFormatted,
BASE_URL: this.appBaseUrl,
});
}The label itself is a declarative template — text blocks, dividers, metadata rows, and a QR code:
// src/printer/templates/project-task.ts
export const projectTaskTemplate: LabelTemplate = {
name: 'project-task',
sections: [
{
type: 'text',
template: '#{{id}}',
align: TextAlignment.ALIGN_LEFT,
charWidth: TextSize.XXS,
charHeight: TextSize.XXS,
},
{
type: 'text',
template: '{{name}}',
align: TextAlignment.ALIGN_LEFT,
charWidth: TextSize.XS,
charHeight: TextSize.S,
bold: true,
autoWrap: true,
},
{ type: 'divider', char: '-' },
{
type: 'text',
template: 'Status:\t\t{{status}}',
charWidth: TextSize.XXS,
charHeight: TextSize.XXS,
},
{
type: 'text',
template: 'Priority:\t{{priority}}',
charWidth: TextSize.XXS,
charHeight: TextSize.XXS,
},
{
type: 'text',
template: 'Project:\t{{projectName}}',
charWidth: TextSize.XXS,
charHeight: TextSize.XXS,
},
{
type: 'text',
template: 'Due:\t\t{{dueDateFormatted}}',
charWidth: TextSize.XXS,
charHeight: TextSize.XXS,
},
{ type: 'divider' },
{
type: 'qrcode',
data: '{{BASE_URL}}/tasks/{{id}}',
align: TextAlignment.ALIGN_CENTER,
moduleSize: 8,
},
],
preFeedLines: 1,
postFeedLines: 8,
cut: 'full',
};You can print a single task or batch-print everything due today. The batch flow queries Notion for incomplete tasks with today's due date:
// src/task/task.service.ts
async getTodaysTasks(): Promise<(Task & { pageId: string })[]> {
const dsId = await this.getDataSourceId(this.taskDbId);
const today = new Date().toISOString().slice(0, 10);
const response = await this.notionService.dataSources.query({
data_source_id: dsId,
filter: {
and: [
{ property: 'Status', status: { does_not_equal: 'Completed' } },
{ property: 'Due Date', date: { equals: today } },
],
},
});
return response.results
.filter((r) => r.object === 'page' && 'properties' in r)
.map((r) => this.pageToTask(r as NotionPage));
}The Daily Workflow
In practice, our daily rhythm looks like this:
- Morning: Print today's tasks. They go on the kitchen board.
- Throughout the day: Pick up a label, do the thing, scan the QR code, mark it done.
- Ad hoc: When something comes up ("the porch light is out"), tell the AI assistant. It creates the task, assigns it, and it shows up in tomorrow's print batch.
The key insight is that we didn't replace Notion's task views — those still work for planning and review. The printer adds a doing layer. Notion is where tasks are managed. Labels are where tasks are executed.
Why This Works for Families
Work task management systems assume everyone is at a computer. Family task management needs to work for someone whose hands are wet from doing dishes, or a kid who can read a label but doesn't have a phone.
The combination of low-friction input (just say what needs doing), structured storage (Notion, browseable and filterable), and physical output (thermal labels with QR codes) covers the full lifecycle without asking anyone to change how they naturally move through their day.
The whole system is a NestJS application, so adding new input channels or output formats is straightforward. But the architecture matters less than the principle: capture should be effortless, visibility should be physical, and the loop back to digital should be one scan away.
Comments ()