chore: checkpoint current tasknotes state

This commit is contained in:
callumalpass 2026-03-25 08:08:28 +11:00
parent 6845a59571
commit c3863baacc
15 changed files with 1649 additions and 116 deletions

View file

@ -11,28 +11,33 @@ remote_title: >-
[Bug]: Default Filter on This Week View Broken for Recurring Tasks
remote_author: bkennedy-improving
remote_url: 'https://github.com/callumalpass/tasknotes/issues/1644'
local_status: triaged
local_status: done
priority: high
difficulty: easy
risk: low
summary: >-
Recurring tasks without a `complete_instances` property are hidden from the
This Week view because `!complete_instances.contains(...)` evaluates to
undefined (falsy) when the property is absent, instead of treating absence as
"not completed today."
Fixed by updating generated default task view filters so a missing
`complete_instances` property is treated as "not completed today," and by
updating the default base template docs to match the generated YAML.
notes: |-
Root cause:
- In `src/templates/defaultBasesFiles.ts`, all recurring task filters use the pattern `!completeInstances.contains(today().format("yyyy-MM-dd"))` without a fallback for when `complete_instances` is undefined/empty.
- When a recurring task has never been completed, `complete_instances` is not present in the frontmatter, so the `.contains()` call on undefined returns undefined rather than true.
- This affects the Today, This Week, Overdue, Not Blocked, and Unscheduled views -- all share the same filter pattern.
Suggested fix (preferred):
- Add `complete_instances.isEmpty()` as an OR clause alongside the `!complete_instances.contains(...)` check in each recurring task filter block in `defaultBasesFiles.ts`. This matches the user's suggested fix and handles the undefined case.
Implemented:
- Updated `src/templates/defaultBasesFiles.ts` so generated recurring-task filters in Not Blocked, Today, Overdue, This Week, and Unscheduled use `complete_instances.isEmpty()` as a fallback alongside `!complete_instances.contains(...)`.
- Updated `docs/views/default-base-templates.md` so the reference examples match the generator output.
- Added a user-facing release note entry in `docs/releases/unreleased.md`.
Fallback options:
- Modify the filter evaluation engine (`BasesFilterConverter` or `FilterService`) to treat `.contains()` on undefined/null as returning false (so `!undefined.contains(x)` returns true).
command_id: triage-issue
last_analyzed_at: '2026-03-22T00:00:00Z'
Verification:
- `npm run build:test` passed.
- `obsidian plugin:reload id=tasknotes` could not be completed from the agent environment because the CLI could not connect to the Obsidian main process.
command_id: address-issue
last_analyzed_at: '2026-03-23T00:00:00Z'
sync_state: clean
type: item_state
---

360
.ops/tasknotes-agent-loop.sh Executable file
View file

@ -0,0 +1,360 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="/home/calluma/projects/tasknotes"
OPS_DIR="$REPO_DIR/.ops"
TASKS_DIR="$OPS_DIR/tasks"
LOG_DIR="$OPS_DIR/logs/agent-loop"
MODE="review"
ITERATIONS=1
DELAY_SECONDS=0
MODEL=""
BRANCH="auto-improvement"
SWITCH_BRANCH=0
DRY_RUN=0
FOCUS_FILE=""
FOCUS_AREAS=(
"tasks view with large datasets, grouping, counts, and virtual scrolling"
"kanban drag and drop, status transitions, card rendering, and column counts"
"calendar recurring tasks, ICS overlays, drag/drop, date edits, and stale rendering"
"agenda and tasks view consistency for the same tasks across filters and grouping"
"quick actions, context menus, and right-click interactions on task cards"
"task creation, convert-to-task flows, and create-or-open-task behavior"
"date editing, reminders, recurrence, and overdue/scheduled metadata rendering"
"pomodoro and time-tracking flows, timer state, and task linkage"
"keyboard-only navigation, focus handling, and accessibility issues"
"large vault or pathological data behavior, including slow renders and broken UI states"
)
usage() {
cat <<'EOF'
Usage: tasknotes-agent-loop.sh [options]
Options:
--mode review|improve Run bug-finding review or improvement pass. Default: review
--iterations N Number of Codex runs. Default: 1
--delay SECONDS Sleep between runs. Default: 0
--model MODEL Optional model to pass to Codex
--branch NAME Improvement branch name. Default: auto-improvement
--switch-branch In improve mode, switch/create the branch before running
--focus-file PATH Optional newline-delimited list of focus areas to rotate through
--dry-run Print prompts and commands without executing Codex
-h, --help Show this help
Examples:
.ops/tasknotes-agent-loop.sh --mode review --iterations 5
.ops/tasknotes-agent-loop.sh --mode improve --iterations 3 --switch-branch
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
MODE="${2:-}"
shift 2
;;
--iterations)
ITERATIONS="${2:-}"
shift 2
;;
--delay)
DELAY_SECONDS="${2:-}"
shift 2
;;
--model)
MODEL="${2:-}"
shift 2
;;
--branch)
BRANCH="${2:-}"
shift 2
;;
--switch-branch)
SWITCH_BRANCH=1
shift
;;
--focus-file)
FOCUS_FILE="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ "$MODE" != "review" && "$MODE" != "improve" ]]; then
echo "Invalid mode: $MODE" >&2
exit 1
fi
if ! [[ "$ITERATIONS" =~ ^[0-9]+$ ]] || [[ "$ITERATIONS" -lt 1 ]]; then
echo "--iterations must be a positive integer" >&2
exit 1
fi
if ! [[ "$DELAY_SECONDS" =~ ^[0-9]+$ ]]; then
echo "--delay must be a non-negative integer" >&2
exit 1
fi
if [[ ! -d "$REPO_DIR/.git" ]]; then
echo "Expected git repo at $REPO_DIR" >&2
exit 1
fi
mkdir -p "$TASKS_DIR" "$LOG_DIR"
load_focus_areas() {
local raw line
if [[ -n "$FOCUS_FILE" ]]; then
if [[ ! -f "$FOCUS_FILE" ]]; then
echo "Focus file not found: $FOCUS_FILE" >&2
exit 1
fi
mapfile -t raw < "$FOCUS_FILE"
FOCUS_AREAS=()
for line in "${raw[@]}"; do
[[ -z "${line// }" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
FOCUS_AREAS+=("$line")
done
if [[ "${#FOCUS_AREAS[@]}" -eq 0 ]]; then
echo "No usable focus areas found in $FOCUS_FILE" >&2
exit 1
fi
fi
}
current_branch() {
git -C "$REPO_DIR" branch --show-current
}
ensure_improvement_branch() {
local branch
branch="$(current_branch)"
if [[ "$SWITCH_BRANCH" -eq 1 ]]; then
if [[ -n "$(git -C "$REPO_DIR" status --short)" ]]; then
echo "Refusing to switch branches with a dirty worktree. Commit/stash first." >&2
exit 1
fi
if git -C "$REPO_DIR" show-ref --verify --quiet "refs/heads/$BRANCH"; then
git -C "$REPO_DIR" switch "$BRANCH"
else
git -C "$REPO_DIR" switch -c "$BRANCH"
fi
branch="$(current_branch)"
fi
if [[ "$branch" == "main" ]]; then
echo "Improve mode must not run on main. Switch to $BRANCH or pass --switch-branch." >&2
exit 1
fi
}
build_review_prompt() {
local focus="$1"
cat <<EOF
You are reviewing the TaskNotes Obsidian plugin for reproducible bugs, regressions, UX failures, performance/pathological issues, and code-level weaknesses that are likely to cause real problems.
Context:
- Repo: $REPO_DIR
- Use Obsidian CLI against: vault=test
- Vault path: /home/calluma/testvault/test
- Output task directory: $TASKS_DIR
- Existing ops registry: $OPS_DIR
- Plugin id: tasknotes
Primary goal for this run:
- Review code related to this focus area: $focus
- Use the Obsidian CLI to exercise the live plugin UI in vault=test
- Prefer findings supported by both code inspection and live reproduction
Rules:
1. Inspect existing files in $TASKS_DIR first to avoid duplicates.
2. Do not modify plugin code in this phase.
3. Do not fix issues in this phase.
4. Do not create weak/speculative tasks.
5. If code review suggests a likely bug, try to confirm it in the test vault before filing.
6. If live testing finds a bug, inspect code enough to suggest likely scope and relevant files.
7. Spend at most 15 minutes or 25 meaningful CLI interactions.
8. Optimize for 1-2 high-confidence findings, not broad coverage.
Useful Obsidian CLI capabilities:
- obsidian plugins filter=community versions format=json vault=test
- obsidian plugin id=tasknotes vault=test
- obsidian commands vault=test
- obsidian command id=<command-id> vault=test
- obsidian dev:debug on
- obsidian dev:errors
- obsidian dev:console level=error limit=50
- obsidian dev:dom selector='<selector>' all
- obsidian dev:screenshot path=<path>
Valid findings include:
- reproducible bug
- clear broken behavior
- obvious UX failure
- performance/pathological behavior
- accessibility or keyboard issue
- stale state, wrong counts, wrong navigation, broken edit flow, rendering glitch, drag/drop issue, or runtime error
- code smells likely to cause real user-facing breakage, especially if confirmed in UI
For each solid issue:
- reproduce it at least twice if feasible
- collect evidence using CLI output, DOM inspection, screenshots, and console/errors when relevant
- write one markdown task file in $TASKS_DIR
Task file requirements:
- One file per issue
- Use a concise slugged filename
- Include frontmatter with at least:
- type: task
- title:
- status: open
- priority:
- area:
- discovered_by: codex
- discovered_at:
- reproduction_confidence:
- In the body include:
- Summary
- Why this is a problem
- Exact reproduction steps
- Expected behavior
- Actual behavior
- Evidence
- Relevant code paths
- Notes on likely scope or hypotheses if useful
- Include exact Obsidian CLI commands used
- Include screenshot file paths if captured
At the end:
- Print a short summary of what you reviewed and tested
- List any new task files created
- If no issue was filed, say so explicitly
EOF
}
build_improve_prompt() {
local focus="$1"
local branch
branch="$(current_branch)"
cat <<EOF
You are implementing one small, reviewable improvement for TaskNotes.
Context:
- Repo: $REPO_DIR
- Current git branch must stay off main. Current branch: $branch
- Preferred long-lived branch: $BRANCH
- Ops task directory: $TASKS_DIR
- Obsidian test vault: /home/calluma/testvault/test using vault=test
- Focus area for this run: $focus
Your job:
1. Inspect open tasks in $TASKS_DIR and choose one high-confidence task that matches or is close to the focus area.
2. Do not start a new exploratory bug hunt unless needed to verify the chosen task.
3. Implement a minimal fix or targeted improvement in the TaskNotes codebase.
4. Run relevant verification.
5. Update the chosen task markdown file with:
- what changed
- files touched
- how it was verified
- screenshots or CLI evidence if relevant
- remaining risks or follow-up work
Rules:
- Do not work on more than one task in this run.
- Keep the change small and reviewable.
- Prefer reproducing the issue before and after the fix when feasible.
- Use the Obsidian CLI with vault=test for live verification when relevant.
- You may edit code in this phase.
- You may add or adjust tests if useful.
- Do not switch branches.
- Do not rewrite unrelated files.
At the end:
- Print the chosen task file
- Summarize the code changes
- Summarize verification performed
- Report whether the task file was updated
EOF
}
run_codex() {
local prompt_file="$1"
local last_msg_file="$2"
local cmd=(
codex exec
--dangerously-bypass-approvals-and-sandbox
-C "$REPO_DIR"
--add-dir "$OPS_DIR"
--output-last-message "$last_msg_file"
)
if [[ -n "$MODEL" ]]; then
cmd+=(--model "$MODEL")
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
printf 'DRY RUN COMMAND:'
printf ' %q' "${cmd[@]}"
printf ' - < %q\n' "$prompt_file"
echo "----- PROMPT BEGIN -----"
cat "$prompt_file"
echo "----- PROMPT END -----"
return 0
fi
"${cmd[@]}" - < "$prompt_file"
}
load_focus_areas
if [[ "$MODE" == "improve" ]]; then
ensure_improvement_branch
fi
for ((i = 0; i < ITERATIONS; i++)); do
focus="${FOCUS_AREAS[$((i % ${#FOCUS_AREAS[@]}))]}"
ts="$(date -u +%Y%m%dT%H%M%SZ)"
run_dir="$LOG_DIR/${MODE}-${ts}-$(printf '%02d' "$((i + 1))")"
mkdir -p "$run_dir"
prompt_file="$run_dir/prompt.txt"
last_msg_file="$run_dir/final-message.txt"
transcript_file="$run_dir/stdout.log"
if [[ "$MODE" == "review" ]]; then
build_review_prompt "$focus" > "$prompt_file"
else
build_improve_prompt "$focus" > "$prompt_file"
fi
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Starting $MODE run $((i + 1))/$ITERATIONS"
echo "Focus: $focus"
echo "Logs: $run_dir"
if [[ "$DRY_RUN" -eq 1 ]]; then
run_codex "$prompt_file" "$last_msg_file"
else
run_codex "$prompt_file" "$last_msg_file" | tee "$transcript_file"
fi
if [[ "$DELAY_SECONDS" -gt 0 && $((i + 1)) -lt "$ITERATIONS" ]]; then
sleep "$DELAY_SECONDS"
fi
done

945
.tmp-conformance-smoke.mjs Normal file
View file

@ -0,0 +1,945 @@
// node_modules/date-fns/constants.js
var daysInYear = 365.2425;
var maxTime = Math.pow(10, 8) * 24 * 60 * 60 * 1e3;
var minTime = -maxTime;
var millisecondsInMinute = 6e4;
var millisecondsInHour = 36e5;
var secondsInHour = 3600;
var secondsInDay = secondsInHour * 24;
var secondsInWeek = secondsInDay * 7;
var secondsInYear = secondsInDay * daysInYear;
var secondsInMonth = secondsInYear / 12;
var secondsInQuarter = secondsInMonth * 3;
var constructFromSymbol = Symbol.for("constructDateFrom");
// node_modules/date-fns/constructFrom.js
function constructFrom(date, value) {
if (typeof date === "function") return date(value);
if (date && typeof date === "object" && constructFromSymbol in date)
return date[constructFromSymbol](value);
if (date instanceof Date) return new date.constructor(value);
return new Date(value);
}
// node_modules/date-fns/toDate.js
function toDate(argument, context) {
return constructFrom(context || argument, argument);
}
// node_modules/date-fns/isDate.js
function isDate(value) {
return value instanceof Date || typeof value === "object" && Object.prototype.toString.call(value) === "[object Date]";
}
// node_modules/date-fns/isValid.js
function isValid(date) {
return !(!isDate(date) && typeof date !== "number" || isNaN(+toDate(date)));
}
// node_modules/date-fns/parseISO.js
function parseISO(argument, options) {
const invalidDate = () => constructFrom(options?.in, NaN);
const additionalDigits = options?.additionalDigits ?? 2;
const dateStrings = splitDateString(argument);
let date;
if (dateStrings.date) {
const parseYearResult = parseYear(dateStrings.date, additionalDigits);
date = parseDate(parseYearResult.restDateString, parseYearResult.year);
}
if (!date || isNaN(+date)) return invalidDate();
const timestamp = +date;
let time = 0;
let offset;
if (dateStrings.time) {
time = parseTime(dateStrings.time);
if (isNaN(time)) return invalidDate();
}
if (dateStrings.timezone) {
offset = parseTimezone(dateStrings.timezone);
if (isNaN(offset)) return invalidDate();
} else {
const tmpDate = new Date(timestamp + time);
const result = toDate(0, options?.in);
result.setFullYear(
tmpDate.getUTCFullYear(),
tmpDate.getUTCMonth(),
tmpDate.getUTCDate()
);
result.setHours(
tmpDate.getUTCHours(),
tmpDate.getUTCMinutes(),
tmpDate.getUTCSeconds(),
tmpDate.getUTCMilliseconds()
);
return result;
}
return toDate(timestamp + time + offset, options?.in);
}
var patterns = {
dateTimeDelimiter: /[T ]/,
timeZoneDelimiter: /[Z ]/i,
timezone: /([Z+-].*)$/
};
var dateRegex = /^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/;
var timeRegex = /^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/;
var timezoneRegex = /^([+-])(\d{2})(?::?(\d{2}))?$/;
function splitDateString(dateString) {
const dateStrings = {};
const array = dateString.split(patterns.dateTimeDelimiter);
let timeString;
if (array.length > 2) {
return dateStrings;
}
if (/:/.test(array[0])) {
timeString = array[0];
} else {
dateStrings.date = array[0];
timeString = array[1];
if (patterns.timeZoneDelimiter.test(dateStrings.date)) {
dateStrings.date = dateString.split(patterns.timeZoneDelimiter)[0];
timeString = dateString.substr(
dateStrings.date.length,
dateString.length
);
}
}
if (timeString) {
const token = patterns.timezone.exec(timeString);
if (token) {
dateStrings.time = timeString.replace(token[1], "");
dateStrings.timezone = token[1];
} else {
dateStrings.time = timeString;
}
}
return dateStrings;
}
function parseYear(dateString, additionalDigits) {
const regex = new RegExp(
"^(?:(\\d{4}|[+-]\\d{" + (4 + additionalDigits) + "})|(\\d{2}|[+-]\\d{" + (2 + additionalDigits) + "})$)"
);
const captures = dateString.match(regex);
if (!captures) return { year: NaN, restDateString: "" };
const year = captures[1] ? parseInt(captures[1]) : null;
const century = captures[2] ? parseInt(captures[2]) : null;
return {
year: century === null ? year : century * 100,
restDateString: dateString.slice((captures[1] || captures[2]).length)
};
}
function parseDate(dateString, year) {
if (year === null) return /* @__PURE__ */ new Date(NaN);
const captures = dateString.match(dateRegex);
if (!captures) return /* @__PURE__ */ new Date(NaN);
const isWeekDate = !!captures[4];
const dayOfYear = parseDateUnit(captures[1]);
const month = parseDateUnit(captures[2]) - 1;
const day = parseDateUnit(captures[3]);
const week = parseDateUnit(captures[4]);
const dayOfWeek = parseDateUnit(captures[5]) - 1;
if (isWeekDate) {
if (!validateWeekDate(year, week, dayOfWeek)) {
return /* @__PURE__ */ new Date(NaN);
}
return dayOfISOWeekYear(year, week, dayOfWeek);
} else {
const date = /* @__PURE__ */ new Date(0);
if (!validateDate(year, month, day) || !validateDayOfYearDate(year, dayOfYear)) {
return /* @__PURE__ */ new Date(NaN);
}
date.setUTCFullYear(year, month, Math.max(dayOfYear, day));
return date;
}
}
function parseDateUnit(value) {
return value ? parseInt(value) : 1;
}
function parseTime(timeString) {
const captures = timeString.match(timeRegex);
if (!captures) return NaN;
const hours = parseTimeUnit(captures[1]);
const minutes = parseTimeUnit(captures[2]);
const seconds = parseTimeUnit(captures[3]);
if (!validateTime(hours, minutes, seconds)) {
return NaN;
}
return hours * millisecondsInHour + minutes * millisecondsInMinute + seconds * 1e3;
}
function parseTimeUnit(value) {
return value && parseFloat(value.replace(",", ".")) || 0;
}
function parseTimezone(timezoneString) {
if (timezoneString === "Z") return 0;
const captures = timezoneString.match(timezoneRegex);
if (!captures) return 0;
const sign = captures[1] === "+" ? -1 : 1;
const hours = parseInt(captures[2]);
const minutes = captures[3] && parseInt(captures[3]) || 0;
if (!validateTimezone(hours, minutes)) {
return NaN;
}
return sign * (hours * millisecondsInHour + minutes * millisecondsInMinute);
}
function dayOfISOWeekYear(isoWeekYear, week, day) {
const date = /* @__PURE__ */ new Date(0);
date.setUTCFullYear(isoWeekYear, 0, 4);
const fourthOfJanuaryDay = date.getUTCDay() || 7;
const diff = (week - 1) * 7 + day + 1 - fourthOfJanuaryDay;
date.setUTCDate(date.getUTCDate() + diff);
return date;
}
var daysInMonths = [31, null, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function isLeapYearIndex(year) {
return year % 400 === 0 || year % 4 === 0 && year % 100 !== 0;
}
function validateDate(year, month, date) {
return month >= 0 && month <= 11 && date >= 1 && date <= (daysInMonths[month] || (isLeapYearIndex(year) ? 29 : 28));
}
function validateDayOfYearDate(year, dayOfYear) {
return dayOfYear >= 1 && dayOfYear <= (isLeapYearIndex(year) ? 366 : 365);
}
function validateWeekDate(_year, week, day) {
return week >= 1 && week <= 53 && day >= 0 && day <= 6;
}
function validateTime(hours, minutes, seconds) {
if (hours === 24) {
return minutes === 0 && seconds === 0;
}
return seconds >= 0 && seconds < 60 && minutes >= 0 && minutes < 60 && hours >= 0 && hours < 25;
}
function validateTimezone(_hours, minutes) {
return minutes >= 0 && minutes <= 59;
}
// src/utils/dateUtils.ts
function parseDate2(dateString) {
if (!dateString) {
const error = new Error("Date string cannot be empty");
console.error("Date parsing error:", { dateString, error: error.message });
throw error;
}
const trimmed = dateString.trim();
try {
const dateWithDayNameMatch = trimmed.match(
/^(\d{4}-\d{2}-\d{2})\s+(Mon|Tue|Wed|Thu|Fri|Sat|Sun|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/i
);
if (dateWithDayNameMatch) {
const dateOnly = dateWithDayNameMatch[1];
return parseDate2(dateOnly);
}
if (trimmed.startsWith("T") && /^T\d{2}:\d{2}(:\d{2})?/.test(trimmed)) {
const error = new Error(`Invalid date format - time without date: ${dateString}`);
console.warn("Date parsing error - incomplete time format:", {
original: dateString,
trimmed,
error: error.message
});
throw error;
}
if (/^\d{4}-W\d{2}$/.test(trimmed)) {
const [year, week] = trimmed.split("-W");
const yearNum = parseInt(year, 10);
const weekNum = parseInt(week, 10);
if (isNaN(yearNum) || isNaN(weekNum)) {
const error = new Error(`Invalid numeric values in ISO week format: ${dateString}`);
console.warn("Date parsing error - invalid ISO week numbers:", {
original: dateString,
year,
week,
yearNum,
weekNum
});
throw error;
}
if (weekNum < 1 || weekNum > 53) {
const error = new Error(
`Invalid week number in ISO week format: ${dateString} (week must be 1-53)`
);
console.warn("Date parsing error - week number out of range:", {
original: dateString,
weekNum,
error: error.message
});
throw error;
}
const jan4 = new Date(yearNum, 0, 4);
const jan4Day = jan4.getDay();
const mondayOfWeek1 = new Date(jan4);
mondayOfWeek1.setDate(jan4.getDate() - (jan4Day === 0 ? 6 : jan4Day - 1));
const targetWeekMonday = new Date(mondayOfWeek1);
targetWeekMonday.setDate(mondayOfWeek1.getDate() + (weekNum - 1) * 7);
if (!isValid(targetWeekMonday)) {
const error = new Error(
`Failed to calculate date from ISO week format: ${dateString}`
);
console.error("Date parsing error - ISO week calculation failed:", {
original: dateString,
yearNum,
weekNum,
jan4: jan4.toISOString(),
targetWeekMonday: targetWeekMonday.toString()
});
throw error;
}
return targetWeekMonday;
}
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?/.test(trimmed)) {
const isoFormat = trimmed.replace(" ", "T");
const parsed = parseISO(isoFormat);
if (!isValid(parsed)) {
const error = new Error(`Invalid space-separated datetime: ${dateString}`);
console.warn("Date parsing error - space-separated datetime invalid:", {
original: dateString,
converted: isoFormat,
error: error.message
});
throw error;
}
return parsed;
}
if (trimmed.includes("T") || trimmed.includes("Z") || trimmed.match(/[+-]\d{2}:\d{2}$/)) {
const parsed = parseISO(trimmed);
if (!isValid(parsed)) {
const error = new Error(`Invalid timezone-aware date: ${dateString}`);
console.warn("Date parsing error - timezone-aware format invalid:", {
original: dateString,
trimmed,
error: error.message
});
throw error;
}
return parsed;
} else {
const dateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!dateMatch) {
const error = new Error(
`Invalid date-only string: ${dateString} (expected format: yyyy-MM-dd)`
);
console.warn("Date parsing error - date-only format invalid:", {
original: dateString,
trimmed,
expectedFormat: "yyyy-MM-dd",
error: error.message
});
throw error;
}
const [, year, month, day] = dateMatch;
const parsed = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
if (!isValid(parsed) || parsed.getFullYear() !== parseInt(year, 10) || parsed.getMonth() !== parseInt(month, 10) - 1 || parsed.getDate() !== parseInt(day, 10)) {
const error = new Error(`Invalid date values: ${dateString}`);
console.warn("Date parsing error - invalid date values:", {
original: dateString,
year,
month,
day,
error: error.message
});
throw error;
}
return parsed;
}
} catch (error) {
if (error instanceof Error && error.message.includes("Invalid date")) {
throw error;
}
const wrappedError = new Error(
`Unexpected error parsing date "${dateString}": ${error instanceof Error ? error.message : String(error)}`
);
console.error("Unexpected date parsing error:", {
original: dateString,
trimmed,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : void 0
});
throw wrappedError;
}
}
function parseDateToUTC(dateString) {
if (!dateString) {
const error = new Error("Date string cannot be empty");
console.error("Date parsing error:", { dateString, error: error.message });
throw error;
}
const trimmed = dateString.trim();
try {
const dateWithDayNameMatch = trimmed.match(
/^(\d{4}-\d{2}-\d{2})\s+(Mon|Tue|Wed|Thu|Fri|Sat|Sun|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/i
);
if (dateWithDayNameMatch) {
const dateOnly = dateWithDayNameMatch[1];
return parseDateToUTC(dateOnly);
}
const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (dateOnlyMatch) {
const [, year, month, day] = dateOnlyMatch;
const yearNum = parseInt(year, 10);
const monthNum = parseInt(month, 10);
const dayNum = parseInt(day, 10);
if (monthNum < 1 || monthNum > 12) {
throw new Error(`Invalid month in date: ${dateString}`);
}
if (dayNum < 1 || dayNum > 31) {
throw new Error(`Invalid day in date: ${dateString}`);
}
const parsed = new Date(Date.UTC(yearNum, monthNum - 1, dayNum));
if (parsed.getUTCFullYear() !== yearNum || parsed.getUTCMonth() !== monthNum - 1 || parsed.getUTCDate() !== dayNum) {
throw new Error(`Invalid date values: ${dateString}`);
}
return parsed;
}
return parseDateToLocal(trimmed);
} catch (error) {
const wrappedError = new Error(`Failed to parse date to UTC: ${trimmed}`);
console.error("Date parsing error:", {
dateString,
trimmed,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : void 0
});
throw wrappedError;
}
}
var parseDateToLocal = parseDate2;
function isSameDateSafe(date1, date2) {
try {
const date1Part = getDatePart(date1);
const date2Part = getDatePart(date2);
const d1 = parseDateToUTC(date1Part);
const d2 = parseDateToUTC(date2Part);
return d1.getTime() === d2.getTime();
} catch (error) {
console.error("Error comparing dates:", { date1, date2, error });
return false;
}
}
function getDatePart(dateString) {
if (!dateString) return "";
try {
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
const tIndex = dateString.indexOf("T");
if (tIndex > -1) {
return dateString.substring(0, tIndex);
}
const parsed = parseDateToUTC(dateString);
return formatDateForStorage(parsed);
} catch (error) {
console.error("Error extracting date part:", { dateString, error });
return dateString;
}
}
function validateCompleteInstances(instances) {
if (!Array.isArray(instances)) {
return [];
}
return instances.filter((instance) => {
if (typeof instance !== "string" || !instance.trim()) {
return false;
}
const trimmed = instance.trim();
if (trimmed.startsWith("T") && /^T\d{2}:\d{2}(:\d{2})?/.test(trimmed)) {
return false;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return false;
}
try {
parseDate2(trimmed);
return true;
} catch (error) {
console.warn(
"Invalid complete_instances entry (date parsing failed):",
instance,
error
);
return false;
}
}).map((instance) => instance.trim());
}
function formatDateForStorage(date) {
try {
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
console.warn("formatDateForStorage received invalid date:", date);
return "";
}
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch (error) {
console.error("Error formatting date for storage:", { date, error });
return "";
}
}
// src/utils/dependencyUtils.ts
import { TFile as TFile2, parseLinktext as parseLinktext2 } from "obsidian";
// src/utils/linkUtils.ts
import { parseLinktext } from "obsidian";
function parseLinkToPath(linkText) {
if (!linkText) return linkText;
const trimmed = linkText.trim();
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
let inner = trimmed.slice(1, -1).trim();
const hasMdExt = /\.md$/i.test(inner);
try {
inner = decodeURIComponent(inner);
} catch (error) {
console.debug("Failed to decode URI component:", inner, error);
}
const parsed = parseLinktext(inner);
return hasMdExt ? inner : parsed.path || inner;
}
if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
const inner = trimmed.slice(2, -2).trim();
const pipeIndex = inner.indexOf("|");
const pathOnly = pipeIndex !== -1 ? inner.substring(0, pipeIndex) : inner;
const parsed = parseLinktext(pathOnly);
return parsed.path;
}
const markdownMatch = trimmed.match(/^\[([^\]]*)\]\(([^)]+)\)$/);
if (markdownMatch) {
let linkPath = markdownMatch[2].trim();
if (linkPath.startsWith("<") && linkPath.endsWith(">")) {
linkPath = linkPath.slice(1, -1).trim();
}
const hasMdExt = /\.md$/i.test(linkPath);
try {
linkPath = decodeURIComponent(linkPath);
} catch (error) {
console.debug("Failed to decode URI component:", linkPath, error);
}
const parsed = parseLinktext(linkPath);
return hasMdExt ? linkPath : parsed.path;
}
return trimmed;
}
// src/utils/dependencyUtils.ts
var DEFAULT_DEPENDENCY_RELTYPE = "FINISHTOSTART";
var VALID_RELATIONSHIP_TYPES = [
"FINISHTOSTART",
"FINISHTOFINISH",
"STARTTOSTART",
"STARTTOFINISH"
];
function isValidDependencyRelType(value) {
return VALID_RELATIONSHIP_TYPES.includes(value);
}
function normalizeDependencyEntry(value) {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
return { uid: parseLinkToPath(trimmed), reltype: DEFAULT_DEPENDENCY_RELTYPE };
}
if (typeof value === "object" && value !== null) {
const raw = value;
const rawUid = typeof raw.uid === "string" ? raw.uid.trim() : "";
if (!rawUid) {
return null;
}
const normalizedUid = parseLinkToPath(rawUid);
const reltypeRaw = typeof raw.reltype === "string" ? raw.reltype.trim().toUpperCase() : "";
const reltype = isValidDependencyRelType(reltypeRaw) ? reltypeRaw : DEFAULT_DEPENDENCY_RELTYPE;
const gap = typeof raw.gap === "string" && raw.gap.trim().length > 0 ? raw.gap.trim() : void 0;
return gap ? { uid: normalizedUid, reltype, gap } : { uid: normalizedUid, reltype };
}
return null;
}
function normalizeDependencyList(value) {
if (value === null || value === void 0) {
return void 0;
}
const arrayValue = Array.isArray(value) ? value : [value];
const normalized = [];
for (const entry of arrayValue) {
const normalizedEntry = normalizeDependencyEntry(entry);
if (normalizedEntry) {
normalized.push(normalizedEntry);
}
}
return normalized.length > 0 ? normalized : void 0;
}
function serializeDependencies(dependencies) {
return dependencies.map((dependency) => {
const uid = dependency.uid.startsWith("[[") ? dependency.uid : `[[${dependency.uid}]]`;
const serialized = {
uid,
reltype: dependency.reltype
};
if (dependency.gap && dependency.gap.trim().length > 0) {
serialized.gap = dependency.gap;
}
return serialized;
});
}
// src/services/FieldMapper.ts
var FieldMapper = class {
constructor(mapping) {
this.mapping = mapping;
}
/**
* Convert internal field name to user's property name
*/
toUserField(internalName) {
return this.mapping[internalName];
}
/**
* Normalize arbitrary title-like values to a string.
* - string: return as-is
* - number/boolean: String(value)
* - array: join elements stringified with ', '
* - object: return empty string (unsupported edge case)
*/
normalizeTitle(val) {
if (typeof val === "string") return val;
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
if (val === null || val === void 0) return void 0;
if (typeof val === "object") return "";
return String(val);
}
/**
* Convert frontmatter object using mapping to internal task data
*/
mapFromFrontmatter(frontmatter, filePath, storeTitleInFilename) {
if (!frontmatter) return {};
const mapped = {
path: filePath
};
if (frontmatter[this.mapping.title] !== void 0) {
const rawTitle = frontmatter[this.mapping.title];
const normalized = this.normalizeTitle(rawTitle);
if (normalized !== void 0) {
mapped.title = normalized;
}
} else if (storeTitleInFilename) {
const filename = filePath.split("/").pop()?.replace(".md", "");
if (filename) {
mapped.title = filename;
}
}
if (frontmatter[this.mapping.status] !== void 0) {
const statusValue = frontmatter[this.mapping.status];
if (typeof statusValue === "boolean") {
mapped.status = statusValue ? "true" : "false";
} else {
mapped.status = statusValue;
}
}
if (frontmatter[this.mapping.priority] !== void 0) {
mapped.priority = frontmatter[this.mapping.priority];
}
if (frontmatter[this.mapping.due] !== void 0) {
mapped.due = frontmatter[this.mapping.due];
}
if (frontmatter[this.mapping.scheduled] !== void 0) {
mapped.scheduled = frontmatter[this.mapping.scheduled];
}
if (frontmatter[this.mapping.contexts] !== void 0) {
const contexts = frontmatter[this.mapping.contexts];
mapped.contexts = Array.isArray(contexts) ? contexts : [contexts];
}
if (frontmatter[this.mapping.projects] !== void 0) {
const projects = frontmatter[this.mapping.projects];
mapped.projects = Array.isArray(projects) ? projects : [projects];
}
if (frontmatter[this.mapping.timeEstimate] !== void 0) {
mapped.timeEstimate = frontmatter[this.mapping.timeEstimate];
}
if (frontmatter[this.mapping.completedDate] !== void 0) {
mapped.completedDate = frontmatter[this.mapping.completedDate];
}
if (frontmatter[this.mapping.recurrence] !== void 0) {
mapped.recurrence = frontmatter[this.mapping.recurrence];
}
if (frontmatter[this.mapping.recurrenceAnchor] !== void 0) {
const anchorValue = frontmatter[this.mapping.recurrenceAnchor];
if (anchorValue === "scheduled" || anchorValue === "completion") {
mapped.recurrence_anchor = anchorValue;
} else {
console.warn(`Invalid recurrence_anchor value: ${anchorValue}, defaulting to 'scheduled'`);
mapped.recurrence_anchor = "scheduled";
}
}
if (frontmatter[this.mapping.dateCreated] !== void 0) {
mapped.dateCreated = frontmatter[this.mapping.dateCreated];
}
if (frontmatter[this.mapping.dateModified] !== void 0) {
mapped.dateModified = frontmatter[this.mapping.dateModified];
}
if (frontmatter[this.mapping.timeEntries] !== void 0) {
const timeEntriesValue = frontmatter[this.mapping.timeEntries];
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
}
if (frontmatter[this.mapping.completeInstances] !== void 0) {
mapped.complete_instances = validateCompleteInstances(
frontmatter[this.mapping.completeInstances]
);
}
if (frontmatter[this.mapping.skippedInstances] !== void 0) {
mapped.skipped_instances = validateCompleteInstances(
frontmatter[this.mapping.skippedInstances]
);
}
if (this.mapping.blockedBy && frontmatter[this.mapping.blockedBy] !== void 0) {
const dependencies = normalizeDependencyList(frontmatter[this.mapping.blockedBy]);
if (dependencies) {
mapped.blockedBy = dependencies;
}
}
if (frontmatter[this.mapping.icsEventId] !== void 0) {
const icsEventId = frontmatter[this.mapping.icsEventId];
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
}
if (frontmatter[this.mapping.googleCalendarEventId] !== void 0) {
mapped.googleCalendarEventId = frontmatter[this.mapping.googleCalendarEventId];
}
if (frontmatter[this.mapping.reminders] !== void 0) {
const reminders = frontmatter[this.mapping.reminders];
if (Array.isArray(reminders)) {
const filteredReminders = reminders.filter((r) => r != null);
if (filteredReminders.length > 0) {
mapped.reminders = filteredReminders;
}
} else if (reminders != null) {
mapped.reminders = [reminders];
}
}
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
mapped.tags = frontmatter.tags;
mapped.archived = frontmatter.tags.includes(this.mapping.archiveTag);
}
return mapped;
}
/**
* Convert internal task data to frontmatter using mapping
*/
mapToFrontmatter(taskData, taskTag, storeTitleInFilename) {
const frontmatter = {};
if (taskData.title !== void 0) {
frontmatter[this.mapping.title] = taskData.title;
}
if (taskData.status !== void 0) {
const lower = taskData.status.toLowerCase();
const coercedValue = lower === "true" || lower === "false" ? lower === "true" : taskData.status;
frontmatter[this.mapping.status] = coercedValue;
}
if (taskData.priority !== void 0) {
frontmatter[this.mapping.priority] = taskData.priority;
}
if (taskData.due !== void 0) {
frontmatter[this.mapping.due] = taskData.due;
}
if (taskData.scheduled !== void 0) {
frontmatter[this.mapping.scheduled] = taskData.scheduled;
}
if (taskData.contexts !== void 0 && (!Array.isArray(taskData.contexts) || taskData.contexts.length > 0)) {
frontmatter[this.mapping.contexts] = taskData.contexts;
}
if (taskData.projects !== void 0 && (!Array.isArray(taskData.projects) || taskData.projects.length > 0)) {
frontmatter[this.mapping.projects] = taskData.projects;
}
if (taskData.timeEstimate !== void 0) {
frontmatter[this.mapping.timeEstimate] = taskData.timeEstimate;
}
if (taskData.completedDate !== void 0) {
frontmatter[this.mapping.completedDate] = taskData.completedDate;
}
if (taskData.recurrence !== void 0) {
frontmatter[this.mapping.recurrence] = taskData.recurrence;
}
if (taskData.recurrence_anchor !== void 0) {
frontmatter[this.mapping.recurrenceAnchor] = taskData.recurrence_anchor;
}
if (taskData.dateCreated !== void 0) {
frontmatter[this.mapping.dateCreated] = taskData.dateCreated;
}
if (taskData.dateModified !== void 0) {
frontmatter[this.mapping.dateModified] = taskData.dateModified;
}
if (taskData.timeEntries !== void 0) {
frontmatter[this.mapping.timeEntries] = taskData.timeEntries;
}
if (taskData.complete_instances !== void 0) {
frontmatter[this.mapping.completeInstances] = taskData.complete_instances;
}
if (taskData.skipped_instances !== void 0 && taskData.skipped_instances.length > 0) {
frontmatter[this.mapping.skippedInstances] = taskData.skipped_instances;
}
if (taskData.blockedBy !== void 0) {
if (Array.isArray(taskData.blockedBy)) {
const normalized = taskData.blockedBy.map((item) => normalizeDependencyEntry(item)).filter((item) => !!item);
if (normalized.length > 0) {
frontmatter[this.mapping.blockedBy] = serializeDependencies(normalized);
}
} else {
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
}
}
if (taskData.icsEventId !== void 0 && taskData.icsEventId.length > 0) {
frontmatter[this.mapping.icsEventId] = taskData.icsEventId;
}
if (taskData.reminders !== void 0 && taskData.reminders.length > 0) {
frontmatter[this.mapping.reminders] = taskData.reminders;
}
let tags = taskData.tags ? [...taskData.tags] : [];
if (taskTag && !tags.includes(taskTag)) {
tags.push(taskTag);
}
if (taskData.archived === true && !tags.includes(this.mapping.archiveTag)) {
tags.push(this.mapping.archiveTag);
} else if (taskData.archived === false) {
tags = tags.filter((tag) => tag !== this.mapping.archiveTag);
}
if (tags.length > 0) {
frontmatter.tags = tags;
}
return frontmatter;
}
/**
* Update mapping configuration
*/
updateMapping(newMapping) {
this.mapping = newMapping;
}
/**
* Get current mapping
*/
getMapping() {
return { ...this.mapping };
}
/**
* Look up the FieldMapping key for a given frontmatter property name.
*
* IMPORTANT: This returns the MAPPING KEY (e.g., "completeInstances"),
* NOT the frontmatter property name (e.g., "complete_instances").
*
* Use this to check if a property is recognized/mapped, but DO NOT use
* the returned key directly as a property identifier for TaskCard.
*
* @param frontmatterPropertyName - The property name from YAML (e.g., "complete_instances")
* @returns The FieldMapping key (e.g., "completeInstances") or null if not found
*
* @example
* // Given mapping: { completeInstances: "complete_instances" }
* lookupMappingKey("complete_instances") // Returns: "completeInstances"
* lookupMappingKey("unknown_field") // Returns: null
*/
lookupMappingKey(frontmatterPropertyName) {
for (const [mappingKey, propertyName] of Object.entries(this.mapping)) {
if (propertyName === frontmatterPropertyName) {
return mappingKey;
}
}
return null;
}
/**
* Check if a frontmatter property name is a recognized/configured field.
* Returns true if the property has a mapping, false otherwise.
*
* @param frontmatterPropertyName - The property name from YAML
* @returns true if the property is recognized, false otherwise
*/
isRecognizedProperty(frontmatterPropertyName) {
return this.lookupMappingKey(frontmatterPropertyName) !== null;
}
/**
* Check if a property name matches a specific internal field.
* This handles user-configured field names properly.
*
* @param propertyName - The property name to check (could be user-configured or internal)
* @param internalField - The internal field key to check against
* @returns true if the propertyName is the user's configured name for this field
*
* @example
* // User has { status: "task-status" }
* isPropertyForField("task-status", "status") // true
* isPropertyForField("status", "status") // false
*
* // User has { status: "status" } (default)
* isPropertyForField("status", "status") // true
*/
isPropertyForField(propertyName, internalField) {
return this.mapping[internalField] === propertyName;
}
/**
* Convert an array of internal field names to their user-configured property names.
*
* @param internalFields - Array of FieldMapping keys
* @returns Array of user-configured property names
*
* @example
* // User has { status: "task-status", due: "deadline" }
* toUserFields(["status", "due", "priority"])
* // Returns: ["task-status", "deadline", "priority"]
*/
toUserFields(internalFields) {
return internalFields.map((field) => this.mapping[field]);
}
/**
* @deprecated Use lookupMappingKey() instead for clarity about what is returned
* Convert user's property name back to internal field name
* This is the reverse of toUserField()
*/
fromUserField(userPropertyName) {
return this.lookupMappingKey(userPropertyName);
}
/**
* Validate that a mapping has no empty field names
*/
static validateMapping(mapping) {
const errors = [];
const fields = Object.keys(mapping);
for (const field of fields) {
if (!mapping[field] || mapping[field].trim() === "") {
errors.push(`Field "${field}" cannot be empty`);
}
}
const values = Object.values(mapping);
const uniqueValues = new Set(values);
if (values.length !== uniqueValues.size) {
errors.push("Field mappings must have unique property names");
}
return {
valid: errors.length === 0,
errors
};
}
};
// src/settings/defaults.ts
var DEFAULT_FIELD_MAPPING = {
title: "title",
status: "status",
priority: "priority",
due: "due",
scheduled: "scheduled",
contexts: "contexts",
projects: "projects",
timeEstimate: "timeEstimate",
completedDate: "completedDate",
dateCreated: "dateCreated",
dateModified: "dateModified",
recurrence: "recurrence",
recurrenceAnchor: "recurrence_anchor",
archiveTag: "archived",
timeEntries: "timeEntries",
completeInstances: "complete_instances",
skippedInstances: "skipped_instances",
blockedBy: "blockedBy",
pomodoros: "pomodoros",
icsEventId: "icsEventId",
icsEventTag: "ics_event",
googleCalendarEventId: "googleCalendarEventId",
reminders: "reminders"
};
// ../../../../tmp/tasknotes-conformance-smoke.ts
console.log(parseDateToUTC("2026-02-20").toISOString().slice(0, 10));
console.log(parseDateToLocal("2026-02-20").toISOString().slice(0, 10));
console.log(isSameDateSafe("2026-02-20", "2026-02-20T09:00:00Z"));
var mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
console.log(mapper.toUserField("title"));

View file

@ -45,3 +45,5 @@ npm run build # Production build (without copying to vault)
---
When you make changes, update docs/releases/unreleased.md. If your changes are related to a GitHub issue or PR, include acknowledgement of the individual who opened the issue or submitted the PR. Do not update unreleased.md for the addition of tests; unreleased.md is user-facing.
Update .ops/ files as you work on items

View file

@ -26,6 +26,10 @@ Example:
## Fixed
- (#1651) Fixed date `is` query filtering so date-only searches also match timed `scheduled` and `due` values during TaskManager prefiltering
- Thanks to @36mimu36 for reporting
- (#1644) Fixed generated default task views so recurring tasks without a `complete_instances` property are still treated as incomplete and appear in views like This Week, Today, and Overdue
- Thanks to @bkennedy-improving for reporting
- Updated generated `_types/task.md` mdbase schema output so `dateCreated` and `dateModified` include generated values (`now` and `now_on_write`) for automatic timestamp handling on create/write
- Fixed documentation deployment CI failures caused by `docs-builder/src/js/main.js` being excluded by a broad `.gitignore` `main.js` rule
- Added a specific unignore rule so the docs site client script is tracked and available in GitHub Actions builds

View file

@ -1,7 +1,7 @@
---
title: Default Base Templates
description: Default base file templates for TaskNotes views
dateModified: 2025-12-02T12:00:00+1100
dateModified: 2026-03-23T18:00:00+1100
---
# Default Base Templates
@ -238,7 +238,7 @@ views:
Used by the **Tasks** command to display filtered task views.
This template includes multiple views: All Tasks, Not Blocked, Today, Overdue, This Week, and Unscheduled. Each view (except All Tasks) filters for incomplete tasks, handling both recurring and non-recurring tasks. The "Not Blocked" view additionally filters for tasks that are ready to work on (no incomplete blocking dependencies).
This template includes multiple views: All Tasks, Not Blocked, Today, Overdue, This Week, and Unscheduled. Each view (except All Tasks) filters for incomplete tasks, handling both recurring and non-recurring tasks. For recurring tasks, the generated filters treat a missing `complete_instances` property as "not completed today" so newly created recurring tasks still appear by default. The "Not Blocked" view additionally filters for tasks that are ready to work on (no incomplete blocking dependencies).
The default views cover common review horizons and can be kept, removed, or cloned with modified filters.
```yaml
@ -283,7 +283,9 @@ views:
# Recurring task where today is not in complete_instances
- and:
- recurrence
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
- or:
- complete_instances.isEmpty()
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
# Not blocked by any incomplete tasks
- or:
# No blocking dependencies at all
@ -319,7 +321,9 @@ views:
# Recurring task where today is not in complete_instances
- and:
- recurrence
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
- or:
- complete_instances.isEmpty()
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
# Due or scheduled today
- or:
- date(due) == today()
@ -353,7 +357,9 @@ views:
# Recurring task where today is not in complete_instances
- and:
- recurrence
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
- or:
- complete_instances.isEmpty()
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
# Due in the past
- date(due) < today()
order:
@ -385,7 +391,9 @@ views:
# Recurring task where today is not in complete_instances
- and:
- recurrence
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
- or:
- complete_instances.isEmpty()
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
# Due or scheduled this week
- or:
- and:
@ -423,7 +431,9 @@ views:
# Recurring task where today is not in complete_instances
- and:
- recurrence
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
- or:
- complete_instances.isEmpty()
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
# No due date and no scheduled date
- date(due).isEmpty()
- date(scheduled).isEmpty()

View file

@ -25,6 +25,7 @@
"test:unit": "jest --testPathPatterns=unit",
"test:performance": "jest --testPathPatterns=performance --detectOpenHandles --passWithNoTests",
"test:build": "node -e \"console.log('Build test passed - plugin bundle created successfully')\"",
"conformance:test": "node scripts/run-tasknotes-spec-conformance.mjs",
"lint": "eslint src/ --ext .ts",
"lint:fix": "eslint src/ --ext .ts --fix",
"format": "prettier --write \"src/**/*.{ts,js,json,md}\"",

View file

@ -0,0 +1,37 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const tasknotesRoot = resolve(scriptDir, "..");
const specRoot = resolve(tasknotesRoot, "../tasknotes-spec");
const adapterPath = resolve(specRoot, "conformance/adapters/tasknotes.adapter.mjs");
function requirePath(path, label) {
if (!existsSync(path)) {
console.error(`Missing ${label}: ${path}`);
process.exit(1);
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env ? { ...process.env, ...options.env } : process.env,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
requirePath(specRoot, "tasknotes-spec repo");
requirePath(resolve(specRoot, "package.json"), "tasknotes-spec package.json");
run("npm", ["run", "conformance:generate"], { cwd: specRoot });
run("npm", ["run", "conformance:build:tasknotes-bridge"], { cwd: specRoot });
run("npm", ["run", "conformance:test"], {
cwd: specRoot,
env: { TASKNOTES_ADAPTER: adapterPath },
});

View file

@ -882,8 +882,8 @@ export class KanbanView extends BasesViewBase {
visibleProperties: string[],
cardOptions: any
): void {
// Make container scrollable with full viewport height
cardsContainer.style.cssText = "overflow-y: auto; max-height: 100vh; position: relative;";
// Use semantic class instead of inline style for easier maintenance.
cardsContainer.addClass("kanban-view__cards--virtual");
// Use containerEl.ownerDocument for pop-out window support
const doc = this.containerEl.ownerDocument;
@ -918,8 +918,8 @@ export class KanbanView extends BasesViewBase {
tasks: TaskInfo[],
visibleProperties: string[]
): Promise<void> {
// Make container scrollable and fill the cell
tasksContainer.style.cssText = "overflow-y: auto; height: 100%; position: relative;";
// Use semantic class instead of inline style for easier maintenance.
tasksContainer.addClass("kanban-view__tasks-container--virtual");
const cardOptions = this.getCardOptions();
@ -982,6 +982,9 @@ export class KanbanView extends BasesViewBase {
const draggingClass = isSwimlaneHeader
? "kanban-view__column-header-cell--dragging"
: "kanban-view__column-header--dragging";
const dragoverClass = isSwimlaneHeader
? "kanban-view__column-header-cell--dragover"
: "kanban-view__column-header--dragover";
header.addEventListener("dragstart", (e: DragEvent) => {
if (!e.dataTransfer) return;
@ -998,14 +1001,14 @@ export class KanbanView extends BasesViewBase {
e.dataTransfer.dropEffect = "move";
// Add visual feedback for drop target
header.classList.add("kanban-view__column-header--dragover");
header.classList.add(dragoverClass);
});
header.addEventListener("dragleave", (e: DragEvent) => {
// Only handle column drags
if (!e.dataTransfer?.types.includes("text/x-kanban-column")) return;
if (e.target === header) {
header.classList.remove("kanban-view__column-header--dragover");
header.classList.remove(dragoverClass);
}
});
@ -1016,7 +1019,7 @@ export class KanbanView extends BasesViewBase {
e.stopPropagation();
// Remove visual feedback
header.classList.remove("kanban-view__column-header--dragover");
header.classList.remove(dragoverClass);
const draggedKey = e.dataTransfer.getData("text/x-kanban-column");
const targetKey = header.dataset.columnKey;
@ -1053,7 +1056,12 @@ export class KanbanView extends BasesViewBase {
header.classList.remove(draggingClass);
});
this.setupColumnHeaderTouchHandlers(header, columnKey, isSwimlaneHeader, draggingClass);
this.setupColumnHeaderTouchHandlers(
header,
columnKey,
isSwimlaneHeader,
draggingClass
);
}
private setupColumnHeaderTouchHandlers(
@ -1359,6 +1367,9 @@ export class KanbanView extends BasesViewBase {
this.boardEl?.querySelectorAll(".kanban-view__column-header--dragover").forEach((el) => {
el.classList.remove("kanban-view__column-header--dragover");
});
this.boardEl?.querySelectorAll(".kanban-view__column-header-cell--dragover").forEach((el) => {
el.classList.remove("kanban-view__column-header-cell--dragover");
});
}
private updateDropTargetFeedback(x: number, y: number): void {
@ -1370,7 +1381,11 @@ export class KanbanView extends BasesViewBase {
} else if (target.type === "swimlane") {
target.element.classList.add("kanban-view__swimlane-column--dragover");
} else if (target.type === "columnHeader" && this.touchDragType === "column") {
target.element.classList.add("kanban-view__column-header--dragover");
if (target.element.classList.contains("kanban-view__column-header-cell")) {
target.element.classList.add("kanban-view__column-header-cell--dragover");
} else {
target.element.classList.add("kanban-view__column-header--dragover");
}
}
}
}
@ -1892,7 +1907,6 @@ export class KanbanView extends BasesViewBase {
const doc = this.containerEl.ownerDocument;
const empty = doc.createElement("div");
empty.className = "tn-bases-empty";
empty.style.cssText = "padding: 20px; text-align: center; color: var(--text-muted);";
empty.textContent = "No TaskNotes tasks found for this Base.";
this.boardEl.appendChild(empty);
}
@ -1903,7 +1917,6 @@ export class KanbanView extends BasesViewBase {
const doc = this.containerEl.ownerDocument;
const error = doc.createElement("div");
error.className = "tn-bases-error";
error.style.cssText = "padding: 20px; text-align: center; color: var(--text-error);";
error.textContent = this.plugin.i18n.translate("views.kanban.errors.noGroupBy");
this.boardEl.appendChild(error);
}
@ -1914,8 +1927,6 @@ export class KanbanView extends BasesViewBase {
const doc = this.containerEl.ownerDocument;
const errorEl = doc.createElement("div");
errorEl.className = "tn-bases-error";
errorEl.style.cssText =
"padding: 20px; color: #d73a49; background: #ffeaea; border-radius: 4px; margin: 10px 0;";
errorEl.textContent = `Error loading kanban: ${error.message || "Unknown error"}`;
this.boardEl.appendChild(errorEl);
}

View file

@ -164,7 +164,6 @@ export class MdbaseSpecService {
this.addRoleField(lines, "recurrenceAnchor", {
type: "enum",
values: ["scheduled", "completion"],
default: "scheduled",
});
this.addField(lines, "tags", { type: "list", items: { type: "string" }, tn_role: "tags" });

View file

@ -491,6 +491,11 @@ ${orderYaml}
.map(status => `${statusProperty} != "${status}"`)
.join('\n - ');
// Treat missing complete_instances as "not completed today" for recurring tasks.
const recurringIncompleteFilter = `or:
- ${completeInstancesProperty}.isEmpty()
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"`;
// Generate filter condition for checking if a blocking task is incomplete
// This is used in the "Not Blocked" view to filter out completed blocking tasks
const blockingTaskIncompleteCondition = completedStatuses
@ -524,7 +529,7 @@ ${orderYaml}
# Recurring task where today is not in complete_instances
- and:
- ${recurrenceProperty}
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
- ${recurringIncompleteFilter}
# Not blocked by any incomplete tasks
- or:
# No blocking dependencies at all
@ -549,7 +554,7 @@ ${orderYaml}
# Recurring task where today is not in complete_instances
- and:
- ${recurrenceProperty}
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
- ${recurringIncompleteFilter}
# Due or scheduled today
- or:
- date(${dueProperty}) == today()
@ -572,7 +577,7 @@ ${orderYaml}
# Recurring task where today is not in complete_instances
- and:
- ${recurrenceProperty}
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
- ${recurringIncompleteFilter}
# Due in the past
- date(${dueProperty}) < today()
order:
@ -593,7 +598,7 @@ ${orderYaml}
# Recurring task where today is not in complete_instances
- and:
- ${recurrenceProperty}
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
- ${recurringIncompleteFilter}
# Due or scheduled this week
- or:
- and:
@ -620,7 +625,7 @@ ${orderYaml}
# Recurring task where today is not in complete_instances
- and:
- ${recurrenceProperty}
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
- ${recurringIncompleteFilter}
# No due date and no scheduled date
- date(${dueProperty}).isEmpty()
- date(${scheduledProperty}).isEmpty()

View file

@ -7,6 +7,7 @@ import {
getTodayString,
formatDateForStorage,
isBeforeDateSafe,
getDatePart,
} from "./dateUtils";
import { calculateTotalTimeSpent } from "./helpers";
import { TaskNotesSettings } from "../types/settings";
@ -342,6 +343,7 @@ export class TaskManager extends Events {
getTasksForDate(date: string): string[] {
const taskPaths: string[] = [];
const files = this.app.vault.getMarkdownFiles();
const targetDate = getDatePart(date);
const scheduledField = this.fieldMapper?.toUserField("scheduled") || "scheduled";
const dueField = this.fieldMapper?.toUserField("due") || "due";
@ -355,8 +357,15 @@ export class TaskManager extends Events {
const scheduled = metadata.frontmatter[scheduledField];
const due = metadata.frontmatter[dueField];
// Check if task is scheduled or due on this date
if (scheduled === date || due === date) {
const scheduledDate =
typeof scheduled === "string" && scheduled.length > 0
? getDatePart(scheduled)
: undefined;
const dueDate =
typeof due === "string" && due.length > 0 ? getDatePart(due) : undefined;
// Match date-only queries against both date-only and datetime frontmatter values.
if (scheduledDate === targetDate || dueDate === targetDate) {
taskPaths.push(file.path);
}
}

View file

@ -206,7 +206,8 @@
}
/* Column header dragover state (for column reordering) */
.tasknotes-plugin .kanban-view__column-header--dragover {
.tasknotes-plugin .kanban-view__column-header--dragover,
.tasknotes-plugin .kanban-view__column-header-cell--dragover {
background: var(--tn-interactive-hover);
border: 2px solid var(--tn-interactive-accent);
transform: scale(1.02);
@ -311,6 +312,14 @@
color: var(--tn-text-normal);
line-height: 1.2;
margin: 0;
min-width: 0;
}
.tasknotes-plugin .kanban-view__column-header .kanban-view__column-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasknotes-plugin .kanban-view__column-icon {
@ -331,80 +340,9 @@
line-height: 1.2;
font-weight: 400;
margin: 0;
}
/* Column Body */
.tasknotes-plugin .kanban-view__column-body {
padding: var(--tn-spacing-sm);
min-height: 100px;
max-height: calc(100vh - 300px);
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: var(--tn-spacing-xs);
}
.tasknotes-plugin .kanban-view__column-body--dragover {
background: var(--tn-interactive-hover);
position: relative;
}
.tasknotes-plugin .kanban-view__column-body--dragover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: color-mix(in srgb, var(--tn-interactive-accent) 0.1, transparent);
border: 2px dashed var(--tn-interactive-accent);
border-radius: var(--tn-radius-md);
pointer-events: none;
}
/* Column Body Scrollbar */
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar {
width: 6px;
}
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar-track {
background: var(--tn-bg-secondary);
border-radius: 3px;
}
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar-thumb {
background: var(--tn-border-color);
border-radius: 3px;
}
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar-thumb:hover {
background: var(--tn-text-muted);
}
/* Empty Column */
.tasknotes-plugin .kanban-view__column-empty {
text-align: center;
color: var(--tn-text-muted);
font-style: italic;
font-size: var(--tn-font-size-sm);
padding: var(--tn-spacing-lg);
border: 2px dashed var(--tn-border-color);
border-radius: var(--tn-radius-md);
background: var(--tn-bg-secondary);
transition: all var(--tn-transition-fast);
margin: var(--tn-spacing-xs);
}
.tasknotes-plugin .kanban-view__column-empty:hover {
border-color: var(--tn-interactive-accent);
background: var(--tn-interactive-hover);
}
.tasknotes-plugin .kanban-view__column--dragover .kanban-view__column-empty {
border-color: var(--tn-interactive-accent);
background: var(--tn-interactive-accent-hover);
color: var(--tn-text-on-accent);
margin-left: var(--tn-spacing-xs);
white-space: nowrap;
flex-shrink: 0;
}
/* ================================================
@ -421,6 +359,18 @@
min-height: 0; /* Allow container to shrink and enable scrolling */
}
/* Virtualized columns need bounded height and relative positioning. */
.tasknotes-plugin .kanban-view__cards--virtual {
max-height: 100vh;
position: relative;
}
/* Virtualized swimlane cells should fill their container. */
.tasknotes-plugin .kanban-view__tasks-container--virtual {
height: 100%;
position: relative;
}
/* Dragging state for card wrappers */
.tasknotes-plugin .kanban-view__card-wrapper[draggable="true"]:hover {
cursor: grab;
@ -483,10 +433,6 @@
padding: var(--tn-spacing-xs) var(--tn-spacing-sm);
}
.tasknotes-plugin .kanban-view__column-body {
padding: var(--tn-spacing-xs);
}
.tasknotes-plugin .kanban-view__new-task-button {
font-size: var(--tn-font-size-xs);
padding: 0 var(--tn-spacing-md);

View file

@ -0,0 +1,9 @@
{
"id": "mdbase-obsidian",
"name": "mdbase",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Schema, type, and validation tools for mdbase collections in Obsidian.",
"author": "calluma",
"authorUrl": "https://github.com/callumalpass"
}

View file

@ -0,0 +1,190 @@
/**
* Reproduction tests for issue #1651.
*
* Reported behavior:
* - Query condition `scheduled is 2026-02-27` should match tasks with
* `scheduled: 2026-02-27T09:00`, but returns no results.
* - Query condition using `is_not_empty` also returns zero results in the
* reported API payload.
*/
import { FilterService } from '../../../src/services/FilterService';
import { TaskManager } from '../../../src/utils/TaskManager';
import { StatusManager } from '../../../src/services/StatusManager';
import { PriorityManager } from '../../../src/services/PriorityManager';
import { FieldMapper } from '../../../src/services/FieldMapper';
import {
DEFAULT_FIELD_MAPPING,
DEFAULT_PRIORITIES,
DEFAULT_SETTINGS,
DEFAULT_STATUSES,
} from '../../../src/settings/defaults';
import { FilterQuery, TaskInfo } from '../../../src/types';
import { MockObsidian } from '../../__mocks__/obsidian';
function makeFilterService() {
const app = MockObsidian.createMockApp();
const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
const settings = {
...DEFAULT_SETTINGS,
taskIdentificationMethod: 'property',
taskPropertyName: 'isTask',
taskPropertyValue: 'true',
} as any;
const cache = new TaskManager(app as any, settings, mapper);
cache.initialize();
const statusManager = new StatusManager(DEFAULT_STATUSES);
const priorityManager = new PriorityManager(DEFAULT_PRIORITIES);
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [] } } as any;
return {
app,
filterService: new FilterService(cache, statusManager, priorityManager, plugin),
};
}
async function createTaskWithTimedScheduledDate(app: any, path: string): Promise<void> {
await app.vault.create(
path,
'---\n' +
'isTask: true\n' +
'title: Timed scheduled task\n' +
'status: open\n' +
'priority: normal\n' +
'scheduled: 2026-02-27T09:00\n' +
'---\n'
);
// Keep explicit string values in metadata for deterministic comparisons.
app.metadataCache.setCache(path, {
frontmatter: {
isTask: true,
title: 'Timed scheduled task',
status: 'open',
priority: 'normal',
scheduled: '2026-02-27T09:00',
},
});
}
async function createTaskWithTimedDueDate(app: any, path: string): Promise<void> {
await app.vault.create(
path,
'---\n' +
'isTask: true\n' +
'title: Timed due task\n' +
'status: open\n' +
'priority: normal\n' +
'due: 2026-02-27T17:30\n' +
'---\n'
);
app.metadataCache.setCache(path, {
frontmatter: {
isTask: true,
title: 'Timed due task',
status: 'open',
priority: 'normal',
due: '2026-02-27T17:30',
},
});
}
function flattenGroups(grouped: Map<string, TaskInfo[]>): TaskInfo[] {
return Array.from(grouped.values()).flat();
}
describe('Issue #1651: Query API scheduled date matching with time components', () => {
beforeEach(() => {
MockObsidian.reset();
});
it('matches `scheduled is YYYY-MM-DD` against `scheduled: YYYY-MM-DDTHH:mm`', async () => {
const { app, filterService } = makeFilterService();
const taskPath = 'Tasks/issue-1651-timed-scheduled.md';
await createTaskWithTimedScheduledDate(app, taskPath);
const query: FilterQuery = {
type: 'group',
id: 'root',
conjunction: 'and',
children: [
{
type: 'condition',
id: 'c1',
property: 'scheduled',
operator: 'is',
value: '2026-02-27',
},
],
sortKey: 'due',
sortDirection: 'asc',
groupKey: 'none',
};
const grouped = await filterService.getGroupedTasks(query);
const matchedTasks = flattenGroups(grouped);
expect(matchedTasks.map((task) => task.path)).toContain(taskPath);
});
it('matches `due is YYYY-MM-DD` against `due: YYYY-MM-DDTHH:mm`', async () => {
const { app, filterService } = makeFilterService();
const taskPath = 'Tasks/issue-1651-timed-due.md';
await createTaskWithTimedDueDate(app, taskPath);
const query: FilterQuery = {
type: 'group',
id: 'root',
conjunction: 'and',
children: [
{
type: 'condition',
id: 'c1',
property: 'due',
operator: 'is',
value: '2026-02-27',
},
],
sortKey: 'due',
sortDirection: 'asc',
groupKey: 'none',
};
const grouped = await filterService.getGroupedTasks(query);
const matchedTasks = flattenGroups(grouped);
expect(matchedTasks.map((task) => task.path)).toContain(taskPath);
});
it.skip('reproduces issue #1651 - API payload using `is_not_empty` should return tasks with scheduled values', async () => {
const { app, filterService } = makeFilterService();
const taskPath = 'Tasks/issue-1651-is-not-empty.md';
await createTaskWithTimedScheduledDate(app, taskPath);
const query = {
type: 'group',
id: 'root',
conjunction: 'and',
children: [
{
type: 'condition',
id: 'c1',
property: 'scheduled',
operator: 'is_not_empty',
value: null,
},
],
sortKey: 'due',
sortDirection: 'asc',
groupKey: 'none',
} as any;
const grouped = await filterService.getGroupedTasks(query);
const matchedTasks = flattenGroups(grouped);
expect(matchedTasks.map((task) => task.path)).toContain(taskPath);
});
});