Add fetch_evening_tasks.py script and shared notion_helpers module

Refactors weekly-template's Notion client setup into a shared helper
(with .env/dotenv support and full pagination), and adds a dedicated
evening-prep script so both commands can fall back to script-based
fetching instead of the MCP search/fetch pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
aj.siegel
2026-09-21 23:05:57 -04:00
parent 339f9b1034
commit 20d8c1aedc
8 changed files with 229 additions and 59 deletions

View File

@@ -30,12 +30,11 @@ Let me know how much unscheduled time I have.
* Directly beneath the Agenda table, if Step 1b found a workout scheduled tomorrow, add a one-line **Workout** note stating whether the 9 AM block is clear or naming the conflicting meeting. * Directly beneath the Agenda table, if Step 1b found a workout scheduled tomorrow, add a one-line **Workout** note stating whether the 9 AM block is clear or naming the conflicting meeting.
## Step 3: Get task data ## Step 3: Get task data
This Notion MCP has no filter/query tool and `view://` URLs aren't fetchable, so use the search → fetch → filter pattern (see CLAUDE.md "HOW TO QUERY THESE DATABASES"). Do NOT try to fetch views and do NOT print any "falling back" message.
1. **Get this week's focus projects** — read the current Weekly Note (`Weekly Notes/Weekly Note - <Sunday's date>.md`) and use its listed focus projects + weekly goals. (Only if that note is missing: `notion-search` the Projects collection `collection://2c0abd6c-450a-807a-a3d7-000b8d8aaecc` with `page_size: 25`, fetch the results in parallel, and keep those with `Focus this week` = `__YES__` and `Status` = "Active".) 1. **Get this week's focus projects** — read the current Weekly Note (`Weekly Notes/Weekly Note - <Sunday's date>.md`) and use its listed focus projects + weekly goals. (Only if that note is missing: `notion-search` the Projects collection `collection://2c0abd6c-450a-807a-a3d7-000b8d8aaecc` with `page_size: 25`, fetch the results in parallel, and keep those with `Focus this week` = `__YES__` and `Status` = "Active".)
2. **Find candidate tasks** — `notion-search` the Tasks collection `collection://2c0abd6c-450a-8085-80a0-000ba136a154` with `page_size: 25`, `max_highlight_length: 0`, and a `query` seeded with the focus-project names plus "blocking due this week suggested today". 2. **Get candidate tasks** — run `python3 scripts/fetch_evening_tasks.py`. This already excludes `Status` = "Done" or "Blocked" and paginates through the full Tasks database, so no candidate is missed as the tracker grows.
3. **Fetch candidate task pages in parallel** to read their real properties (`Status`, `Suggested for today`, `Due date`, `Blocking`, `Priority`, `Project`). 3. **Rank locally**: by (1) `has_blocking` true, (2) `suggested_for_today` ≤ tomorrow, (3) soonest `due_date`.
4. **Filter & rank locally**: drop `Status` = "Done" or "Blocked". Rank the rest by (1) `Blocking` populated, (2) `Suggested for today` ≤ tomorrow, (3) soonest `Due date`.
**If the script errors or is unavailable** (e.g. missing `.env`/`NOTION_TOKEN` on this machine), fall back instead of failing the command: `notion-search` the Tasks collection `collection://2c0abd6c-450a-8085-80a0-000ba136a154` with `page_size: 25`, `max_highlight_length: 0`, seeded with the focus-project names plus "blocking due this week suggested today" (this Notion MCP has no filter/query tool and `view://` URLs aren't fetchable — see CLAUDE.md "HOW TO QUERY THESE DATABASES"). Fetch candidate pages in parallel to read `Status`, `Suggested for today`, `Due date`, `Blocking`, `Priority`, `Project`, then filter/rank as in step 3 above. Do NOT print any "falling back" message for `view://` fetches, but DO tell me the script failed and you're using the fallback.
Format the note with the following sections: Format the note with the following sections:
1. Under **Suggested Tasks** list up to 3 tasks from the ranked set above (Blocking-others tasks always come first). For each, show the task name, related project, and a Notion link. 1. Under **Suggested Tasks** list up to 3 tasks from the ranked set above (Blocking-others tasks always come first). For each, show the task name, related project, and a Notion link.
@@ -46,8 +45,8 @@ Format the note with the following sections:
The Tasks Tracker has a `Top Task` checkbox property and a `Top Tasks` view (filtered to `Top Task = true` AND `Status != Done`) for surfacing the current day's Big 3 at a glance. At most 3 tasks may be checked at any time. The Tasks Tracker has a `Top Task` checkbox property and a `Top Tasks` view (filtered to `Top Task = true` AND `Status != Done`) for surfacing the current day's Big 3 at a glance. At most 3 tasks may be checked at any time.
1. Present the Suggested Tasks from Step 3 to AJ and let him confirm or swap any of them before finalizing. 1. Present the Suggested Tasks from Step 3 to AJ and let him confirm or swap any of them before finalizing.
2. `notion-search` the Tasks collection (`collection://2c0abd6c-450a-8085-80a0-000ba136a154`) filtered/queried for `Top Task` = true (or check the pages already known from recent notes) to find any tasks still marked from a prior day. 2. Use `marked_top_tasks` from the Step 3 script output (already every task with `Top Task = true`, any status — no extra search needed). If the Step 3 fallback was used instead, `notion-search` the Tasks collection (`collection://2c0abd6c-450a-8085-80a0-000ba136a154`) filtered/queried for `Top Task` = true.
3. Any currently-marked task whose `Status` = "Done" gets `Top Task` set to false immediately — regardless of whether it's in today's agreed set (it can't be, since Step 3.4 already drops Done tasks). Don't wait for a replacement to unmark a completed task. 3. Any currently-marked task whose `status` = "Done" gets `Top Task` set to false immediately — regardless of whether it's in today's agreed set. Don't wait for a replacement to unmark a completed task.
4. For each other currently-marked task that is NOT in today's agreed set, `notion-update-page` to set `Top Task` = false. 4. For each other currently-marked task that is NOT in today's agreed set, `notion-update-page` to set `Top Task` = false.
5. For each of the (up to 3) agreed tasks, `notion-update-page` to set `Top Task` = true. 5. For each of the (up to 3) agreed tasks, `notion-update-page` to set `Top Task` = true.
6. Never have more than 3 tasks checked at once — unmark old ones before marking new ones. 6. Never have more than 3 tasks checked at once — unmark old ones before marking new ones.

View File

@@ -11,15 +11,14 @@ Also remove all prior weekly notes that are more than 1 week old.
Please create a new note in the "Weekly Notes" folder. Please create a new note in the "Weekly Notes" folder.
The title should be "Weekly Note - Month, Day Year" using today's date. The title should be "Weekly Note - Month, Day Year" using today's date.
## Step 2: Review Notion data and create the note ## Step 2: Fetch Notion data and create the note
This Notion MCP has no filter/query tool and `view://` URLs aren't fetchable, so use the search → fetch → filter pattern (see CLAUDE.md "HOW TO QUERY THESE DATABASES"). Do NOT try to fetch views and do NOT print any "falling back" message. Run `python3 scripts/fetch_weekly_data.py` to get `projects`, `tasks`, and `reading` in one JSON payload (this reduces MCP round-trips and inference cost). `projects` is already filtered to active, non-personal projects and includes each one's current `focus_this_week` state. `tasks` is already filtered to non-Done tasks due within 7 days. `reading` is already filtered to "To Read"/"In Progress".
1. **Active projects** — `notion-search` the Projects collection `collection://2c0abd6c-450a-807a-a3d7-000b8d8aaecc` with `page_size: 25`, `max_highlight_length: 0`. Fetch the returned pages **in parallel**, then keep those with `Status` = "Active" and `Type` ≠ "Personal". **If the script errors or is unavailable** (e.g. missing `.env`/`NOTION_TOKEN` on this machine), fall back to the search → fetch → filter pattern instead of failing the command (see CLAUDE.md "HOW TO QUERY THESE DATABASES"): `notion-search` the Projects collection `collection://2c0abd6c-450a-807a-a3d7-000b8d8aaecc` and Tasks collection `collection://2c0abd6c-450a-8085-80a0-000ba136a154` (both `page_size: 25`, `max_highlight_length: 0`), fetch results in parallel, and apply the same filters manually. Do NOT try to fetch `view://` URLs and do NOT print any "falling back" message for those — but DO mention to me that the script failed and you're using the fallback.
2. **This week's tasks** — `notion-search` the Tasks collection `collection://2c0abd6c-450a-8085-80a0-000ba136a154` with `page_size: 25`, `max_highlight_length: 0`, seeding `query` with the active-project names plus "due this week blocking". Fetch the results in parallel and drop anything with `Status` = "Done".
List all active projects and ask me which 3 I want to prioritize for the upcoming week. List all active projects and ask me which 3 I want to prioritize for the upcoming week.
If there isn't a weekly goal for the selected projects, ask me to write one. If there isn't a weekly goal for the selected projects, ask me to write one.
Update the Focus this week field to true for the selected projects (and set to false for any previously selected projects not in my new list). Update the Focus this week field to true for the selected projects (and false for any project where `focus_this_week` was already true but isn't in my new list) via the Notion MCP — the script is read-only and can't make this update.
Then add the following to the note Then add the following to the note
@@ -43,9 +42,9 @@ Fetch the Runna ical feed (see CLAUDE.md "Data Sources") with `curl -sL <url>`.
Add a **Workouts this week** section: a table with columns Date, Workout, Block (e.g. 9:00–9:50 AM), and Status (Clear, or "Conflict: <meeting name> <time>"). Skip days with no scheduled workout. Add a **Workouts this week** section: a table with columns Date, Workout, Block (e.g. 9:00–9:50 AM), and Status (Clear, or "Conflict: <meeting name> <time>"). Skip days with no scheduled workout.
## Step 4: Generate my reading list ## Step 4: Generate my reading list
Using the same search → fetch → filter pattern: `notion-search` the Reading List collection `collection://2c4abd6c-450a-804a-809a-000bc77d02c1` with `page_size: 25`, `max_highlight_length: 0`. Fetch the returned pages in parallel and keep those with `Status` = "To Read" or "In Progress". (Don't fetch `view://` URLs.) Use the `reading` list already fetched in Step 2 (from `fetch_weekly_data.py`, or its fallback). If the Step 2 fallback was used and didn't cover reading, `notion-search` the Reading List collection `collection://2c4abd6c-450a-804a-809a-000bc77d02c1` with `page_size: 25`, `max_highlight_length: 0`, fetch the returned pages in parallel, and keep those with `Status` = "To Read" or "In Progress". (Don't fetch `view://` URLs.)
3. Under **Suggested Reading** recommend 2 articles that fit the available time blocks: Recommend 2 articles under **Suggested Reading** that fit the available time blocks:
- Look at the unscheduled time across the week (from Step 3) to determine what reading time is appropriate - Look at the unscheduled time across the week (from Step 3) to determine what reading time is appropriate
- Match articles based on Read time field: - Match articles based on Read time field:
* Short = <5 minutes (fits in small gaps) * Short = <5 minutes (fits in small gaps)

4
.gitignore vendored
View File

@@ -46,3 +46,7 @@ Temporary Items
# Environment # Environment
.env .env
# Python
__pycache__/
*.pyc

View File

@@ -1,4 +1,4 @@
# Weekly Planning Scripts # Daily/Weekly Planning Scripts
## Setup ## Setup
@@ -24,30 +24,38 @@ For each database (Projects, Tasks Tracker, Reading List):
2. Click "..." menu → "Connections" 2. Click "..." menu → "Connections"
3. Add your "Weekly Planning Script" integration 3. Add your "Weekly Planning Script" integration
### 4. Set environment variable ### 4. Set your Notion token
Add to your `~/.zshrc` or `~/.bash_profile`: Create a `.env` file in the project root:
```bash ```bash
export NOTION_TOKEN="secret_your_token_here" NOTION_TOKEN="secret_your_token_here"
``` ```
Then reload: `source ~/.zshrc` (`.env` is gitignored, and the script loads it automatically via `python-dotenv`.)
## Usage ## Usage
Run the script to fetch weekly planning data: ### `fetch_weekly_data.py`
```bash ```bash
python3 scripts/fetch_weekly_data.py python3 scripts/fetch_weekly_data.py
``` ```
Or add it to your PATH and run directly:
```bash
./scripts/fetch_weekly_data.py
```
This outputs JSON with: This outputs JSON with:
- Projects marked "Focus this week" - Active, non-personal projects (candidates for the weekly focus selection)
- Tasks due within the next 7 days - Tasks due within the next 7 days
- Reading list articles (To Read or In Progress) - Reading list articles (To Read or In Progress)
The weekly-template skill automatically calls this script to generate your weekly note, reducing AI inference costs. The `/weekly-template` command calls this script to generate your weekly note, reducing AI inference costs. If the script fails, the command falls back to the Notion MCP search/fetch pattern.
### `fetch_evening_tasks.py`
```bash
python3 scripts/fetch_evening_tasks.py
```
This outputs JSON with:
- All open tasks (not Done, not Blocked) — paginated, so it stays complete as the tracker grows
- Every task currently marked `Top Task = true` (any status), for unmarking stale ones
The `/evening-prep` command calls this script to build the day's Suggested Tasks and manage the `Top Task` checkbox, with the same MCP fallback if the script fails.
Both scripts share Notion client setup, database IDs, and pagination logic in `notion_helpers.py`.

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Fetch candidate tasks for evening planning.
Returns JSON with all open (non-Done, non-Blocked) tasks, for local ranking
by blocking status, "Suggested for today", and due date.
"""
import json
import sys
from datetime import datetime
from notion_helpers import notion, get_data_source_id, query_all, TASKS_DB
def get_evening_tasks():
"""Get all tasks that are not Done and not Blocked."""
pages = query_all(
get_data_source_id(TASKS_DB),
filter={
"and": [
{
"property": "Status",
"status": {
"does_not_equal": "Done"
}
},
{
"property": "Status",
"status": {
"does_not_equal": "Blocked"
}
}
]
},
sorts=[
{
"property": "Due date",
"direction": "ascending"
}
]
)
tasks = []
for page in pages:
props = page["properties"]
due_date = None
if props["Due date"]["date"]:
due_date = props["Due date"]["date"]["start"]
suggested_for_today = None
if props["Suggested for today"]["date"]:
suggested_for_today = props["Suggested for today"]["date"]["start"]
project_names = []
if props["Project"]["relation"]:
for rel in props["Project"]["relation"]:
try:
project = notion.pages.retrieve(page_id=rel["id"])
project_name = project["properties"]["Name"]["title"][0]["plain_text"]
project_names.append(project_name)
except:
pass
has_blocking = len(props["Blocking"]["relation"]) > 0 if props["Blocking"]["relation"] else False
tasks.append({
"id": page["id"],
"url": page["url"],
"name": props["Task name"]["title"][0]["plain_text"] if props["Task name"]["title"] else "",
"due_date": due_date,
"suggested_for_today": suggested_for_today,
"status": props["Status"]["status"]["name"] if props["Status"]["status"] else "",
"priority": props["Priority"]["select"]["name"] if props["Priority"]["select"] else "",
"effort_level": props["Effort level"]["select"]["name"] if props["Effort level"]["select"] else "",
"energy_type": props["Energy type"]["select"]["name"] if props["Energy type"]["select"] else "",
"task_type": [t["name"] for t in props["Task type"]["multi_select"]],
"project": project_names[0] if project_names else "",
"has_blocking": has_blocking,
"top_task": props["Top Task"]["checkbox"]
})
return tasks
def get_marked_top_tasks():
"""Get all tasks currently marked Top Task = true, regardless of status.
Queried separately (without the Done/Blocked exclusion) so a stale Top Task
left checked on a now-Done task is still found and can be unmarked.
"""
pages = query_all(
get_data_source_id(TASKS_DB),
filter={
"property": "Top Task",
"checkbox": {
"equals": True
}
}
)
return [
{
"id": page["id"],
"name": page["properties"]["Task name"]["title"][0]["plain_text"] if page["properties"]["Task name"]["title"] else "",
"status": page["properties"]["Status"]["status"]["name"] if page["properties"]["Status"]["status"] else ""
}
for page in pages
]
def main():
try:
data = {
"tasks": get_evening_tasks(),
"marked_top_tasks": get_marked_top_tasks(),
"generated_at": datetime.now().isoformat()
}
print(json.dumps(data, indent=2))
return 0
except Exception as e:
print(json.dumps({"error": str(e)}), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -5,54 +5,42 @@ Returns JSON with projects, tasks, and reading suggestions.
""" """
import json import json
import os
import sys import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from notion_client import Client
# Initialize Notion client - will use NOTION_TOKEN env var from notion_helpers import notion, get_data_source_id, query_all, PROJECTS_DB, TASKS_DB, READING_LIST_DB
notion_token = os.getenv('NOTION_TOKEN')
if not notion_token:
print(json.dumps({"error": "NOTION_TOKEN environment variable not set. See scripts/README.md for setup instructions."}), file=sys.stderr)
sys.exit(1)
notion = Client(auth=notion_token) def get_active_projects():
"""Get non-personal projects with Status = Active (candidates for weekly focus selection)."""
# Database IDs pages = query_all(
PROJECTS_DB = "2c0abd6c450a8090aca3e0b2b0373c17" get_data_source_id(PROJECTS_DB),
TASKS_DB = "2c0abd6c450a805098d3cc0e7d3dfccf"
READING_LIST_DB = "2c4abd6c450a80cbae55c440dd9e2427"
def get_focus_projects():
"""Get projects with 'Focus this week' = true and Status = Active."""
results = notion.databases.query(
database_id=PROJECTS_DB,
filter={ filter={
"and": [ "and": [
{
"property": "Focus this week",
"checkbox": {
"equals": True
}
},
{ {
"property": "Status", "property": "Status",
"select": { "select": {
"equals": "Active" "equals": "Active"
} }
},
{
"property": "Type",
"select": {
"does_not_equal": "Personal"
}
} }
] ]
} }
) )
projects = [] projects = []
for page in results["results"]: for page in pages:
props = page["properties"] props = page["properties"]
projects.append({ projects.append({
"id": page["id"], "id": page["id"],
"url": page["url"], "url": page["url"],
"name": props["Name"]["title"][0]["plain_text"] if props["Name"]["title"] else "", "name": props["Name"]["title"][0]["plain_text"] if props["Name"]["title"] else "",
"weekly_goal": props["Weekly goal"]["rich_text"][0]["plain_text"] if props["Weekly goal"]["rich_text"] else "", "weekly_goal": props["Weekly goal"]["rich_text"][0]["plain_text"] if props["Weekly goal"]["rich_text"] else "",
"focus_this_week": props["Focus this week"]["checkbox"],
"icon": page.get("icon", {}).get("emoji", "") "icon": page.get("icon", {}).get("emoji", "")
}) })
@@ -64,8 +52,8 @@ def get_weekly_tasks():
today = datetime.now().date() today = datetime.now().date()
week_end = today + timedelta(days=7) week_end = today + timedelta(days=7)
results = notion.databases.query( pages = query_all(
database_id=TASKS_DB, get_data_source_id(TASKS_DB),
filter={ filter={
"and": [ "and": [
{ {
@@ -91,7 +79,7 @@ def get_weekly_tasks():
) )
tasks = [] tasks = []
for page in results["results"]: for page in pages:
props = page["properties"] props = page["properties"]
# Get due date # Get due date
@@ -131,8 +119,8 @@ def get_weekly_tasks():
def get_reading_suggestions(): def get_reading_suggestions():
"""Get articles with Status = 'To Read' or 'In Progress'.""" """Get articles with Status = 'To Read' or 'In Progress'."""
results = notion.databases.query( pages = query_all(
database_id=READING_LIST_DB, get_data_source_id(READING_LIST_DB),
filter={ filter={
"or": [ "or": [
{ {
@@ -152,7 +140,7 @@ def get_reading_suggestions():
) )
articles = [] articles = []
for page in results["results"]: for page in pages:
props = page["properties"] props = page["properties"]
articles.append({ articles.append({
"id": page["id"], "id": page["id"],
@@ -168,7 +156,7 @@ def get_reading_suggestions():
def main(): def main():
try: try:
data = { data = {
"projects": get_focus_projects(), "projects": get_active_projects(),
"tasks": get_weekly_tasks(), "tasks": get_weekly_tasks(),
"reading": get_reading_suggestions(), "reading": get_reading_suggestions(),
"generated_at": datetime.now().isoformat() "generated_at": datetime.now().isoformat()

46
scripts/notion_helpers.py Normal file
View File

@@ -0,0 +1,46 @@
"""Shared Notion client setup and query helpers for the FullFocus scripts."""
import json
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from notion_client import Client
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
_notion_token = os.getenv('NOTION_TOKEN')
if not _notion_token:
print(json.dumps({"error": "NOTION_TOKEN environment variable not set. See scripts/README.md for setup instructions."}), file=sys.stderr)
sys.exit(1)
notion = Client(auth=_notion_token)
# Database IDs
PROJECTS_DB = "2c0abd6c450a8090aca3e0b2b0373c17"
TASKS_DB = "2c0abd6c450a805098d3cc0e7d3dfccf"
READING_LIST_DB = "2c4abd6c450a80cbae55c440dd9e2427"
_data_source_id_cache = {}
def get_data_source_id(database_id):
"""Resolve a database ID to its first data source ID (notion-client 3.x API)."""
if database_id not in _data_source_id_cache:
db = notion.databases.retrieve(database_id=database_id)
_data_source_id_cache[database_id] = db["data_sources"][0]["id"]
return _data_source_id_cache[database_id]
def query_all(data_source_id, **kwargs):
"""Query a data source, following pagination until all matching pages are collected."""
pages = []
start_cursor = None
while True:
if start_cursor:
kwargs["start_cursor"] = start_cursor
results = notion.data_sources.query(data_source_id=data_source_id, **kwargs)
pages.extend(results["results"])
if not results.get("has_more"):
break
start_cursor = results["next_cursor"]
return pages

View File

@@ -1 +1,2 @@
notion-client>=2.0.0 notion-client>=2.0.0
python-dotenv>=1.0.0