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
57 changes: 57 additions & 0 deletions .changeset/todo-remove-inert-derived-flags.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/example-todo": patch
---

fix(example-todo): remove the inert `is_completed` / `is_overdue` flags and repair every filter that read them (#7226)

`examples/app-todo/src/objects/task.object.ts` declared `is_completed` and
`is_overdue` as `readonly: true` booleans defaulting to `false`. Nothing in the
app ever wrote either one — no hook leg, no flow node, no action handler, and
the seed data set neither — so both were `false` on every row for the life of
the app, while **twelve** view / dashboard / report / flow filters read them as
if they were maintained.

The consequence was not cosmetic. Every surface asking `is_completed: true` was
permanently empty: the "Completed Today" tile, the "Weekly Task Completion"
trend, and both the "Completed Tasks" and "Time Tracking" reports. So was the
whole "Overdue Tasks" list view, which asked `is_overdue: true`. The eight
surfaces asking `is_completed: false` were vacuously true instead — they matched
completed tasks too. `task.hook.ts` also carried an `afterUpdate` branch gated
on `data.is_overdue && previous && !previous.is_overdue`, which could never run.
Since #7036 started stamping `completed_date` on the completion transition, the
divergence was directly readable in the shipped app: a task could carry a
completion date and `is_completed: false` at the same time.

**Removed rather than derived as formula fields, for a measured reason.** A
`Field.formula(...)` computes both correctly — including the temporal one
(`date(record.due_date) < today()` evaluates per read, with a per-call `now`
snapshot) — so deriving looks like the obvious repair. It is not: a `formula`
field is virtual, no driver materialises a column for it, and so a *filter*
naming one matches nothing. Measured on this app's own sqlite-wasm driver,
`where { is_completed: false }` against a formula field returns **0 rows with no
error**, where the stored boolean returned every row. Deriving would therefore
have silently emptied the "Due Today" view, the daily reminder flow and both
open-task reports — trading a wrong answer for an invisible one.

`status` and `due_date` are stored, indexed columns that already carry the
information, and both are declared dimensions on the `task_metrics` dataset, so
every consumer now asks the semantic layer's own vocabulary directly:

| was | is now |
|---|---|
| `is_completed == true` | `status equals 'completed'` |
| `is_completed == false` | `status not_equals 'completed'` |
| `is_overdue == true` | `due_date less_than '{today}'` AND `status not_equals 'completed'` |

Updated across `task.object.ts`, `task.hook.ts`, `task.view.ts`,
`task.dashboard.ts`, `task.report.ts`, `task.flow.ts`, the three translation
bundles and the README. The hook's dead overdue branch is removed rather than
re-armed against `due_date`: becoming overdue is the passage of time, not a
record write, so a record hook is structurally the wrong instrument — the
clock-driven `overdue_escalation` scheduled flow already covers it.

Pinned by `examples/app-todo/test/derived-flag-removal.test.ts`, which walks the
app's real `defineStack` for any surviving reference, drives the replacement
filters across **both** sides of the completion transition (so a filter cannot
pass for the same reason the old flag did — everything being false), and records
the formula-filter measurement that decided the route.
7 changes: 4 additions & 3 deletions examples/app-todo/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ examples/app-todo/
- ✅ **Select** (`status`, `priority`, `category`) — Single-select with colors
- ✅ **Multi-Select** (`tags`) — Multiple tag selection
- ✅ **Date / DateTime** (`due_date`, `reminder_date`, `completed_date`)
- ✅ **Boolean** (`is_completed`, `is_overdue`, `is_recurring`)
- ✅ **Boolean** (`is_recurring`)
- ✅ **Number** (`estimated_hours`, `actual_hours`, `recurrence_interval`)
- ✅ **Percent** (`progress_percent`) — Progress tracking
- ✅ **Lookup** (`owner`) — User assignment
Expand DownExpand Up@@ -90,8 +90,9 @@ examples/app-todo/
### Validations & Automation
- Completed date required when status is "completed" (validation rule)
- Recurrence type required for recurring tasks (validation rule)
- Auto-set `is_completed`, `completed_date`, `progress_percent` on status
change (data hook)
- Auto-set `completed_date` on the completion transition, cleared on reopen
(data hook). Completion and overdue state are read from `status` / `due_date`
directly — the app declares no derived boolean flags (#7226)
- Auto-detect overdue tasks and send urgent notifications (flow)

## 💡 How to Run
Expand Down
12 changes: 6 additions & 6 deletions examples/app-todo/src/dashboards/task.dashboard.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@ export const TaskDashboard: Dashboard = {
id: 'completed_today',
title: 'Completed Today',
type: 'metric',
filter: { is_completed: true, completed_date: { $gte: '{today}' } },
filter: { status: 'completed', completed_date: { $gte: '{today}' } },
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 3, y: 0, w: 3, h: 2 },
Expand All@@ -42,7 +42,7 @@ export const TaskDashboard: Dashboard = {
id: 'overdue_tasks',
title: 'Overdue Tasks',
type: 'metric',
filter: { is_overdue: true, is_completed: false },
filter: { due_date: { $lt: '{today}' }, status: { $ne: 'completed' } },
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 6, y: 0, w: 3, h: 2 },
Expand All@@ -66,7 +66,7 @@ export const TaskDashboard: Dashboard = {
id: 'tasks_by_status',
title: 'Tasks by Status',
type: 'pie',
filter: { is_completed: false },
filter: { status: { $ne: 'completed' } },
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
Expand All@@ -78,7 +78,7 @@ export const TaskDashboard: Dashboard = {
id: 'tasks_by_priority',
title: 'Tasks by Priority',
type: 'bar',
filter: { is_completed: false },
filter: { status: { $ne: 'completed' } },
dataset: 'task_metrics',
dimensions: ['priority'],
values: ['task_count'],
Expand All@@ -92,7 +92,7 @@ export const TaskDashboard: Dashboard = {
id: 'weekly_task_completion',
title: 'Weekly Task Completion',
type: 'line',
filter: { is_completed: true, completed_date: { $gte: '{4_weeks_ago}' } },
filter: { status: 'completed', completed_date: { $gte: '{4_weeks_ago}' } },
dataset: 'task_metrics',
dimensions: ['completed_date'],
values: ['task_count'],
Expand All@@ -104,7 +104,7 @@ export const TaskDashboard: Dashboard = {
id: 'tasks_by_category',
title: 'Tasks by Category',
type: 'donut',
filter: { is_completed: false },
filter: { status: { $ne: 'completed' } },
dataset: 'task_metrics',
dimensions: ['category'],
values: ['task_count'],
Expand Down
6 changes: 3 additions & 3 deletions examples/app-todo/src/flows/task.flow.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,7 @@ export const TaskReminderFlow: Flow = {
// `limit > 1` is the declared way to make this a LIST read (`find`, not
// `findOne`) — the undeclared `getAll` that sat here was never read, so
// this sweep silently fetched a single task (#4277 rejects the key now).
config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', is_completed: false }, outputVariable: 'tasksToRemind', limit: 200 },
config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', status: { $ne: 'completed' } }, outputVariable: 'tasksToRemind', limit: 200 },
},
{
id: 'loop_tasks', type: 'loop', label: 'Loop Through Tasks',
Expand DownExpand Up@@ -75,7 +75,7 @@ export const OverdueEscalationFlow: Flow = {
// `limit > 1` = LIST read; the undeclared `getAll` was never read (#4277).
config: {
objectName: 'todo_task',
filter: { due_date: { $lt: '{3_days_ago}' }, is_completed: false, is_overdue: true },
filter: { due_date: { $lt: '{3_days_ago}' }, status: { $ne: 'completed' } },
outputVariable: 'overdueTasks', limit: 200,
},
},
Expand DownExpand Up@@ -245,7 +245,7 @@ export const TaskCompletionFlow: Flow = {
// A whole-string token, so `interpolate()` hands the create the RAW
// value the script node returned instead of a stringified copy.
due_date: '{nextDueDate}',
status: 'not_started', is_completed: false,
status: 'not_started',
},
outputVariable: 'newTaskId',
},
Expand Down
17 changes: 13 additions & 4 deletions examples/app-todo/src/objects/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,10 +105,19 @@ const taskHook: Hook = {
// Could trigger notifications or integrations here
}

// Check if task became overdue
if (data.is_overdue && previous && !previous.is_overdue) {
logger?.info?.(`Task ${ctx.input.id} is now overdue`);
}
// [#7226] A "task became overdue" leg USED TO SIT HERE, gated on
// `data.is_overdue && previous && !previous.is_overdue`. It could never
// run: `is_overdue` was a `readonly` boolean nothing ever wrote, so
// `data.is_overdue` was absent on every update and the branch was dead
// code that read as working automation.
//
// It is not re-armed against `due_date`, and that is deliberate. Becoming
// overdue is the passage of TIME, not a record write — a task nobody
// touches crosses its due date with no update to observe, so a record hook
// is structurally the wrong instrument and any version of this branch would
// fire late, or never. The clock-driven sweep already exists in the right
// place: `flows/task.flow.ts`'s `overdue_escalation`, a scheduled flow that
// runs daily and selects on `due_date` directly.
}
}
};
Expand Down
44 changes: 31 additions & 13 deletions examples/app-todo/src/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,19 +125,37 @@ export const Task = ObjectSchema.create({
min: 1,
}),

// Flags
is_completed: Field.boolean({
label: 'Is Completed',
defaultValue: false,
readonly: true,
}),

is_overdue: Field.boolean({
label: 'Is Overdue',
defaultValue: false,
readonly: true,
}),

// [#7226] `is_completed` / `is_overdue` USED TO LIVE HERE, and were removed.
//
// Both were `readonly: true` booleans defaulting to `false` that nothing in
// the app ever wrote — no hook leg, no flow node, no action handler, and the
// seed data set neither. They were therefore `false` on every row for the
// life of the app, while twelve view / dashboard / report / flow filters
// read them as if they were maintained. `is_completed: true` tiles ("Completed
// Today", "Weekly Task Completion", the two completed-task reports) were
// permanently empty, and the divergence became visible once #7036 started
// stamping `completed_date` on the completion transition: a task could carry
// a completion date and `is_completed: false` at the same time.
//
// They are GONE rather than derived, and the reason is measured, not
// stylistic. `Field.formula(...)` computes correctly for both — including the
// temporal one (`date(record.due_date) < today()` evaluates per read, with a
// per-call `now` snapshot) — but a formula field is VIRTUAL: no driver
// materialises a column for it, so a FILTER naming one matches nothing.
// Measured on this app's own sqlite-wasm driver: `where { is_completed: false }`
// against a formula field returns 0 rows with no error, where the stored
// boolean returned every row. Deriving them would have silently emptied the
// "Due Today" view, the reminder flow and both open-task reports — trading a
// wrong answer for an invisible one.
//
// `status` and `due_date` are stored, indexed columns that already carry the
// information, so every consumer now asks them directly:
// is_completed == true -> status equals 'completed'
// is_completed == false -> status not_equals 'completed'
// is_overdue == true -> due_date less_than '{today}' AND status not_equals 'completed'
// Consistent by construction, with no second writer that can drift — which is
// the pattern a reference app should be teaching.

// Progress
progress_percent: Field.percent({
label: 'Progress (%)',
Expand Down
8 changes: 4 additions & 4 deletions examples/app-todo/src/reports/task.report.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ export const TasksByPriorityReport = defineReport({
dataset: 'task_metrics',
rows: ['priority'],
values: ['task_count'],
runtimeFilter: { is_completed: false },
runtimeFilter: { status: { $ne: 'completed' } },
});

/** Tasks by Owner Report */
Expand All@@ -40,7 +40,7 @@ export const TasksByOwnerReport = defineReport({
dataset: 'task_metrics',
rows: ['owner'],
values: ['est_hours', 'actual_hours'],
runtimeFilter: { is_completed: false },
runtimeFilter: { status: { $ne: 'completed' } },
});

// ADR-0021 Phase 2: the former `OverdueTasksReport` (a flat record list, no
Expand All@@ -57,7 +57,7 @@ export const CompletedTasksReport = defineReport({
dataset: 'task_metrics',
rows: ['category'],
values: ['est_hours', 'actual_hours'],
runtimeFilter: { is_completed: true },
runtimeFilter: { status: 'completed' },
});

/** Time Tracking Report */
Expand All@@ -72,5 +72,5 @@ export const TimeTrackingReport = defineReport({
dataset: 'task_metrics',
rows: ['owner', 'category'],
values: ['est_hours', 'actual_hours'],
runtimeFilter: { is_completed: true },
runtimeFilter: { status: 'completed' },
});
2 changes: 0 additions & 2 deletions examples/app-todo/src/translations/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,8 +71,6 @@ export const en: TranslationData = {
},
},
recurrence_interval: { label: 'Recurrence Interval' },
is_completed: { label: 'Is Completed' },
is_overdue: { label: 'Is Overdue' },
progress_percent: { label: 'Progress (%)' },
estimated_hours: { label: 'Estimated Hours' },
actual_hours: { label: 'Actual Hours' },
Expand Down
2 changes: 0 additions & 2 deletions examples/app-todo/src/translations/ja-JP.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,8 +70,6 @@ export const jaJP: TranslationData = {
},
},
recurrence_interval: { label: '繰り返し間隔' },
is_completed: { label: '完了済み' },
is_overdue: { label: '期限超過' },
progress_percent: { label: '進捗率 (%)' },
estimated_hours: { label: '見積時間' },
actual_hours: { label: '実績時間' },
Expand Down
2 changes: 0 additions & 2 deletions examples/app-todo/src/translations/zh-CN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,8 +74,6 @@ export const zhCN: TranslationData = {
},
},
recurrence_interval: { label: '重复间隔' },
is_completed: { label: '是否完成' },
is_overdue: { label: '是否逾期' },
progress_percent: { label: '进度 (%)' },
estimated_hours: { label: '预估工时' },
actual_hours: { label: '实际工时' },
Expand Down
12 changes: 9 additions & 3 deletions examples/app-todo/src/views/task.view.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,9 +46,13 @@ export const TaskViews = defineView({
{ field: 'owner' },
{ field: 'category' },
],
// [#7226] "Overdue" asked the removed `is_overdue`/`is_completed` flags,
// which nothing maintained — so this view was permanently EMPTY. It now
// asks the stored, indexed columns that carry the same fact: past due, and
// not finished. `{today}` is the platform date macro, resolved per request.
filter: [
{ field: 'is_overdue', operator: 'equals', value: true },
{ field: 'is_completed', operator: 'equals', value: false },
{ field: 'due_date', operator: 'less_than', value: '{today}' },
{ field: 'status', operator: 'not_equals', value: 'completed' },
],
sort: [{ field: 'due_date', order: 'asc' }],
},
Expand All@@ -65,9 +69,11 @@ export const TaskViews = defineView({
{ field: 'owner' },
{ field: 'category' },
],
// [#7226] `is_completed == false` was vacuously true for every row; it now
// asks `status` directly so a completed task really does drop out.
filter: [
{ field: 'due_date', operator: 'equals', value: '{today}' },
{ field: 'is_completed', operator: 'equals', value: false },
{ field: 'status', operator: 'not_equals', value: 'completed' },
],
sort: [{ field: 'priority', order: 'desc' }],
},
Expand Down
Loading
Loading