Skip to content

Commit a79955b

Browse files
committed
feat(someday): add Someday sections and keep Someday projects as rows (#1090)
1 parent ce6aef8 commit a79955b

62 files changed

Lines changed: 1389 additions & 309 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cloud/src/server-config.test.ts

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,6 @@ describe('area name length limit', () => {
3838

3939
const sorted = (values: Iterable<string>): string[] => Array.from(values).sort();
4040

41-
// Frozen snapshot of the pre-refactor hand-written literals (2026-07-20 generative-schema
42-
// refactor). CLOUD_TASK_CREATION_ALLOWED_PROP_KEYS and CLOUD_TASK_PATCH_ALLOWED_PROP_KEYS are
43-
// now derived from TASK_SYNC_FIELD_SCHEMA's cloudWrite flag instead of hand-maintained Sets;
44-
// this proves the derived output is unchanged. Do not update this list to match a schema
45-
// change — grow the schema and leave this alone, the same as the schema tests in
46-
// packages/core/src/task-sync-schema.test.ts.
47-
const PRE_REFACTOR_CLOUD_TASK_CREATION_ALLOWED_PROP_KEYS = [
48-
'status', 'priority', 'taskMode', 'startTime', 'relativeStartOffset', 'dueDate', 'recurrence',
49-
'showFutureRecurrence', 'pushCount', 'tags', 'contexts', 'checklist', 'description',
50-
'textDirection', 'attachments', 'location', 'projectId', 'sectionId', 'areaId',
51-
'isFocusedToday', 'energyLevel', 'assignedTo', 'timeEstimate', 'timeSpentMinutes', 'reviewAt',
52-
'suppressMindwtrReminders', 'repeatReminderMinutes',
53-
];
54-
55-
const PRE_REFACTOR_CLOUD_TASK_PATCH_ALLOWED_PROP_KEYS = [
56-
'title', 'order', 'orderNum', 'boardOrder', 'focusOrder',
57-
...PRE_REFACTOR_CLOUD_TASK_CREATION_ALLOWED_PROP_KEYS,
58-
];
59-
6041
describe('cloud Task schema contract', () => {
6142
it('keeps creation validation aligned with schema write semantics', () => {
6243
const expected = TASK_SYNC_FIELD_SCHEMA
@@ -73,21 +54,13 @@ describe('cloud Task schema contract', () => {
7354

7455
expect(sorted(CLOUD_TASK_PATCH_ALLOWED_PROP_KEYS)).toEqual(sorted(expected));
7556
});
76-
77-
it('derives CLOUD_TASK_CREATION_ALLOWED_PROP_KEYS identical to the pre-refactor literal', () => {
78-
expect(sorted(CLOUD_TASK_CREATION_ALLOWED_PROP_KEYS)).toEqual(sorted(PRE_REFACTOR_CLOUD_TASK_CREATION_ALLOWED_PROP_KEYS));
79-
});
80-
81-
it('derives CLOUD_TASK_PATCH_ALLOWED_PROP_KEYS identical to the pre-refactor literal', () => {
82-
expect(sorted(CLOUD_TASK_PATCH_ALLOWED_PROP_KEYS)).toEqual(sorted(PRE_REFACTOR_CLOUD_TASK_PATCH_ALLOWED_PROP_KEYS));
83-
});
8457
});
8558

8659
// Frozen snapshot of the pre-refactor hand-written literals (parity-entities follow-up to the
8760
// 2026-07-20 generative-schema refactor). CLOUD_PROJECT_*/CLOUD_SECTION_* allowlists are now
8861
// derived from PROJECT_SYNC_FIELD_SCHEMA / SECTION_SYNC_FIELD_SCHEMA's cloudWrite flag instead
8962
// of hand-maintained Sets. Do not update these lists to match a schema change — grow the
90-
// schema and leave this alone, the same as PRE_REFACTOR_CLOUD_TASK_* above.
63+
// schema and leave this alone; these snapshots cover the unchanged entity descriptors only.
9164
const PRE_REFACTOR_CLOUD_PROJECT_CREATION_ALLOWED_PROP_KEYS = [
9265
'status', 'color', 'order', 'tagIds', 'isSequential', 'taskSortBy', 'isFocused',
9366
'supportNotes', 'attachments', 'dueDate', 'reviewAt', 'areaId', 'areaTitle',

apps/cloud/src/server-validation.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,22 @@ function validateTaskRecurrence(value: Record<string, unknown>): string | null {
9797
return null;
9898
}
9999

100+
function validateTaskViewSectionIds(value: Record<string, unknown>): string | null {
101+
if (!hasOwnField(value, 'viewSectionIds')) return null;
102+
const ids = value.viewSectionIds;
103+
if (ids === undefined || ids === null) return null;
104+
if (!isRecord(ids)) return 'Invalid task viewSectionIds';
105+
return Object.values(ids).every((sectionId) => typeof sectionId === 'string')
106+
? null
107+
: 'Invalid task viewSectionIds';
108+
}
109+
100110
function validateTaskPropValues(value: Record<string, unknown>): string | null {
101111
return validateTaskRepeatReminderMinutes(value)
102112
?? validateTaskTimeSpentMinutes(value)
103113
?? validateTaskRelativeStartOffset(value)
104-
?? validateTaskRecurrence(value);
114+
?? validateTaskRecurrence(value)
115+
?? validateTaskViewSectionIds(value);
105116
}
106117

107118
function validateProjectPropValues(value: Record<string, unknown>): string | null {

apps/cloud/src/server.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,47 @@ describe('cloud server utils', () => {
727727
expect(invalidRecurrence.error).toContain('recurrence');
728728
});
729729

730+
test('accepts Task.viewSectionIds on the deployed open task shape', () => {
731+
const baseTask = makeTestTask({ id: 'view-section-task', title: 'Task' });
732+
const result = validateAppData({
733+
tasks: [{
734+
...baseTask,
735+
viewSectionIds: {
736+
someday: 'books',
737+
},
738+
}],
739+
projects: [],
740+
settings: {
741+
gtd: {
742+
viewSections: {
743+
someday: [{ id: 'books', title: 'Books to read', order: 0 }],
744+
},
745+
},
746+
},
747+
});
748+
expect(result.ok).toBe(true);
749+
});
750+
751+
test('validates forward-compatible viewSectionIds values without allowlisting scope keys', () => {
752+
const baseTask = makeTestTask({ id: 'view-section-task', title: 'Task' });
753+
const futureScope = validateAppData({
754+
tasks: [{
755+
...baseTask,
756+
viewSectionIds: { futureScopeAddedByNewerClient: 'future-heading' },
757+
}],
758+
projects: [],
759+
});
760+
expect(futureScope.ok).toBe(true);
761+
762+
const invalid = validateAppData({
763+
tasks: [{ ...baseTask, viewSectionIds: { someday: 42 } }],
764+
projects: [],
765+
});
766+
expect(invalid.ok).toBe(false);
767+
if (invalid.ok) throw new Error('Expected invalid viewSectionIds');
768+
expect(invalid.error).toContain('viewSectionIds');
769+
});
770+
730771
test('validates settings.attachments.pendingRemoteDeletes structure', () => {
731772
const iso = '2024-01-01T00:00:00.000Z';
732773
const base = {

apps/desktop/src-tauri/src/macos_cloudkit_bridge.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ typedef NS_ENUM(NSInteger, MWFieldKind) {
166166
{"location", "location", MWFieldKindString},
167167
{"projectId", "projectId", MWFieldKindString},
168168
{"sectionId", "sectionId", MWFieldKindString},
169+
{"viewSectionIds", "viewSectionIds", MWFieldKindJsonString},
169170
{"areaId", "areaId", MWFieldKindString},
170171
{"isFocusedToday", "isFocusedToday", MWFieldKindBool},
171172
{"timeEstimate", "timeEstimate", MWFieldKindString},

apps/desktop/src-tauri/src/storage.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const SNAPSHOT_RETENTION_RECENT_COUNT: usize = 2;
3737
const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000;
3838
const STORAGE_RETRY_ATTEMPTS: usize = 4;
3939
const STORAGE_RETRY_BASE_DELAY_MS: u64 = 120;
40-
const STORAGE_SCHEMA_VERSION: i64 = 5;
40+
const STORAGE_SCHEMA_VERSION: i64 = 6;
4141
const STORAGE_SCHEMA_STATE_TABLE: &str = "storage_schema_state";
4242
// Version 4 adds assignedTo to the desktop-native FTS schema and forces one
4343
// content rebuild after the corrected triggers are installed.
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS tasks (
8585
location TEXT,
8686
projectId TEXT REFERENCES projects(id) ON DELETE SET NULL,
8787
sectionId TEXT REFERENCES sections(id) ON DELETE SET NULL,
88+
viewSectionIds TEXT,
8889
areaId TEXT REFERENCES areas(id) ON DELETE SET NULL,
8990
orderNum INTEGER,
9091
boardOrder INTEGER,
@@ -518,6 +519,7 @@ fn initialize_sqlite_schema(conn: &mut Connection) -> Result<i64, String> {
518519
ensure_column(&transaction, "tasks", "focusOrder", "INTEGER")?;
519520
ensure_tasks_area_column(&transaction)?;
520521
ensure_tasks_section_column(&transaction)?;
522+
ensure_column(&transaction, "tasks", "viewSectionIds", "TEXT")?;
521523
ensure_tasks_organization_indexes(&transaction)?;
522524
ensure_projects_order_column(&transaction)?;
523525
ensure_column(&transaction, "projects", "sequentialScope", "TEXT")?;
@@ -1676,10 +1678,11 @@ fn replace_task_row(conn: &Connection, task: &Value) -> Result<(), String> {
16761678
let recurrence_json = json_str(task.get("recurrence"));
16771679
let checklist_json = json_str(task.get("checklist"));
16781680
let attachments_json = json_str(task.get("attachments"));
1681+
let view_section_ids_json = json_str(task.get("viewSectionIds"));
16791682
let normalized_rev = normalized_revision_for_storage(task.get("rev"));
16801683
let normalized_rev_by = normalized_rev_by(task.get("revBy"));
16811684
conn.execute(
1682-
"INSERT OR REPLACE INTO tasks (id, title, status, priority, energyLevel, assignedTo, taskMode, startTime, relativeStartOffset, dueDate, recurrence, showFutureRecurrence, pushCount, tags, contexts, checklist, description, textDirection, attachments, location, projectId, sectionId, areaId, orderNum, boardOrder, focusOrder, isFocusedToday, timeEstimate, suppressMindwtrReminders, repeatReminderMinutes, reviewAt, completedAt, statusBeforeProjectArchive, completedAtBeforeProjectArchive, isFocusedTodayBeforeProjectArchive, projectArchivedAt, rev, revBy, createdAt, updatedAt, deletedAt, purgedAt, timeSpentMinutes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43)",
1685+
"INSERT OR REPLACE INTO tasks (id, title, status, priority, energyLevel, assignedTo, taskMode, startTime, relativeStartOffset, dueDate, recurrence, showFutureRecurrence, pushCount, tags, contexts, checklist, description, textDirection, attachments, location, projectId, sectionId, viewSectionIds, areaId, orderNum, boardOrder, focusOrder, isFocusedToday, timeEstimate, suppressMindwtrReminders, repeatReminderMinutes, reviewAt, completedAt, statusBeforeProjectArchive, completedAtBeforeProjectArchive, isFocusedTodayBeforeProjectArchive, projectArchivedAt, rev, revBy, createdAt, updatedAt, deletedAt, purgedAt, timeSpentMinutes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44)",
16831686
params![
16841687
task.get("id").and_then(|v| v.as_str()).unwrap_or_default(),
16851688
task.get("title").and_then(|v| v.as_str()).unwrap_or_default(),
@@ -1703,6 +1706,7 @@ fn replace_task_row(conn: &Connection, task: &Value) -> Result<(), String> {
17031706
task.get("location").and_then(|v| v.as_str()),
17041707
task.get("projectId").and_then(|v| v.as_str()),
17051708
task.get("sectionId").and_then(|v| v.as_str()),
1709+
view_section_ids_json,
17061710
task.get("areaId").and_then(|v| v.as_str()),
17071711
task.get("order")
17081712
.and_then(|v| v.as_f64())
@@ -1879,6 +1883,11 @@ fn row_to_task_value(row: &rusqlite::Row<'_>) -> Result<Value, rusqlite::Error>
18791883
map.insert("sectionId".to_string(), Value::String(v));
18801884
}
18811885
}
1886+
let view_section_ids_raw: Option<String> = row.get("viewSectionIds")?;
1887+
let view_section_ids_val = parse_json_value(view_section_ids_raw);
1888+
if !view_section_ids_val.is_null() {
1889+
map.insert("viewSectionIds".to_string(), view_section_ids_val);
1890+
}
18821891
if let Ok(val) = row.get::<_, Option<String>>("areaId") {
18831892
if let Some(v) = val {
18841893
map.insert("areaId".to_string(), Value::String(v));
@@ -3171,8 +3180,9 @@ fn replace_data_in_transaction(conn: &Connection, mut data: Value) -> Result<Val
31713180
let recurrence_json = json_str(task.get("recurrence"));
31723181
let checklist_json = json_str(task.get("checklist"));
31733182
let attachments_json = json_str(task.get("attachments"));
3183+
let view_section_ids_json = json_str(task.get("viewSectionIds"));
31743184
conn.execute(
3175-
"INSERT OR REPLACE INTO tasks (id, title, status, priority, energyLevel, assignedTo, taskMode, startTime, relativeStartOffset, dueDate, recurrence, showFutureRecurrence, pushCount, tags, contexts, checklist, description, textDirection, attachments, location, projectId, sectionId, areaId, orderNum, boardOrder, focusOrder, isFocusedToday, timeEstimate, suppressMindwtrReminders, repeatReminderMinutes, reviewAt, completedAt, statusBeforeProjectArchive, completedAtBeforeProjectArchive, isFocusedTodayBeforeProjectArchive, projectArchivedAt, rev, revBy, createdAt, updatedAt, deletedAt, purgedAt, timeSpentMinutes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43)",
3185+
"INSERT OR REPLACE INTO tasks (id, title, status, priority, energyLevel, assignedTo, taskMode, startTime, relativeStartOffset, dueDate, recurrence, showFutureRecurrence, pushCount, tags, contexts, checklist, description, textDirection, attachments, location, projectId, sectionId, viewSectionIds, areaId, orderNum, boardOrder, focusOrder, isFocusedToday, timeEstimate, suppressMindwtrReminders, repeatReminderMinutes, reviewAt, completedAt, statusBeforeProjectArchive, completedAtBeforeProjectArchive, isFocusedTodayBeforeProjectArchive, projectArchivedAt, rev, revBy, createdAt, updatedAt, deletedAt, purgedAt, timeSpentMinutes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44)",
31763186
params![
31773187
task.get("id").and_then(|v| v.as_str()).unwrap_or_default(),
31783188
task.get("title").and_then(|v| v.as_str()).unwrap_or_default(),
@@ -3196,6 +3206,7 @@ fn replace_data_in_transaction(conn: &Connection, mut data: Value) -> Result<Val
31963206
task.get("location").and_then(|v| v.as_str()),
31973207
task.get("projectId").and_then(|v| v.as_str()),
31983208
task.get("sectionId").and_then(|v| v.as_str()),
3209+
view_section_ids_json,
31993210
task.get("areaId").and_then(|v| v.as_str()),
32003211
task.get("order")
32013212
.and_then(|v| v.as_f64())
@@ -5693,6 +5704,10 @@ mod tests {
56935704
"location": "Office",
56945705
"projectId": "project-full",
56955706
"sectionId": "section-1",
5707+
"viewSectionIds": {
5708+
"someday": "someday-books",
5709+
"future-scope": "future-heading"
5710+
},
56965711
"areaId": "area-1",
56975712
"order": 17,
56985713
"boardOrder": 4,
@@ -5809,6 +5824,7 @@ mod tests {
58095824
"location",
58105825
"projectId",
58115826
"sectionId",
5827+
"viewSectionIds",
58125828
"areaId",
58135829
"order",
58145830
"boardOrder",

apps/desktop/src/components/InboxProcessingQuickPanel.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useRef, type KeyboardEvent } from 'react';
22
import { ArrowRight, BookOpen, CheckCircle, ClipboardList, Clock, Hourglass, Trash2, User, X } from 'lucide-react';
3-
import { DEFAULT_PROJECT_COLOR, filterProjectsBySelectedArea, formatTimeEstimateLabel, safeFormatDate, safeParseDate, tFallback, type Project, type Task, type TaskDraft, type TaskDraftSetter, type TaskPriority, type TimeEstimate,
3+
import { DEFAULT_PROJECT_COLOR, filterProjectsBySelectedArea, formatTimeEstimateLabel, resolveTaskViewSection, safeFormatDate, safeParseDate, setTaskViewSectionId, sortViewSectionDefinitions, tFallback, type AppData, type Project, type Task, type TaskDraft, type TaskDraftSetter, type TaskPriority, type TimeEstimate,
44
numericTextCollator,
55
} from '@mindwtr/core';
66

@@ -38,6 +38,7 @@ export type InboxProcessingQuickPanelProps = {
3838
setField: TaskDraftSetter;
3939
visibility: InboxProcessingVisibility;
4040
options: InboxProcessingOptionLists;
41+
settings?: AppData['settings'];
4142
processingMode: 'guided' | 'quick';
4243
onModeChange: (mode: 'guided' | 'quick') => void;
4344
onSkip: () => void;
@@ -108,6 +109,7 @@ export function InboxProcessingQuickPanel({
108109
setField,
109110
visibility,
110111
options,
112+
settings,
111113
processingMode,
112114
onModeChange,
113115
onSkip,
@@ -186,6 +188,29 @@ export function InboxProcessingQuickPanel({
186188
const setSelectedTimeEstimate = (value: TimeEstimate | undefined) => setField('timeEstimate', value ?? '');
187189
const setSelectedProjectId = (value: string | null) => setField('projectId', value ?? '');
188190
const setSelectedAreaId = (value: string | null) => setField('areaId', value ?? '');
191+
const somedaySections = sortViewSectionDefinitions(settings?.gtd?.viewSections?.someday ?? []);
192+
const selectedSomedaySection = resolveTaskViewSection(draft, 'someday', somedaySections);
193+
const somedaySectionField = somedaySections.length > 0 ? (
194+
<div className="space-y-1">
195+
<label className="text-[11px] text-muted-foreground font-medium">
196+
{tFallback(t, 'viewSections.somedaySection', 'Someday section')}
197+
</label>
198+
<select
199+
aria-label={tFallback(t, 'viewSections.somedaySection', 'Someday section')}
200+
value={selectedSomedaySection?.id ?? ''}
201+
onChange={(event) => setField(
202+
'viewSectionIds',
203+
setTaskViewSectionId(draft.viewSectionIds, 'someday', event.target.value || undefined),
204+
)}
205+
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"
206+
>
207+
<option value="">{tFallback(t, 'viewSections.noSection', 'No section')}</option>
208+
{somedaySections.map((section) => (
209+
<option key={section.id} value={section.id}>{section.title}</option>
210+
))}
211+
</select>
212+
</div>
213+
) : null;
189214

190215
const showActionFields = actionabilityChoice === 'actionable';
191216
const showLaterFields = actionabilityChoice === 'later';
@@ -589,9 +614,12 @@ export function InboxProcessingQuickPanel({
589614
</div>
590615
) : null}
591616

592-
{showDeferredOrganizationFields && organizationContainerFields ? (
617+
{showDeferredOrganizationFields && (organizationContainerFields || somedaySectionField) ? (
593618
<div className="rounded-lg border border-status-someday/20 bg-status-someday/5 p-3">
594-
{organizationContainerFields}
619+
<div className="space-y-3">
620+
{organizationContainerFields}
621+
{somedaySectionField}
622+
</div>
595623
</div>
596624
) : null}
597625

0 commit comments

Comments
 (0)