Initial scaffold of Weekly Music Practice Planner

Obsidian community plugin that creates one markdown note per week
under <Folder>/<YYYY>/<YYYY>-Www.md. Week start day is configurable;
weeks that cross a year boundary stay under the year they started.

Includes settings tab (folder, week-start day, default skills) and
commands to open this/next/previous week's plan.
This commit is contained in:
pdriggett 2026-06-14 15:39:24 -04:00
commit b920116cb6
12 changed files with 2960 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
node_modules/
main.js
*.js.map
data.json
.DS_Store
.vscode/
.idea/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Patrick Driggett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

45
README.md Normal file
View file

@ -0,0 +1,45 @@
# Weekly Music Practice Planner
An [Obsidian](https://obsidian.md) community plugin for planning and tracking weekly music practice.
Each week is a single markdown note in your vault containing:
- **Goals** for the week
- A **skills × days** tracking table
- **Daily notes** sections, one per day
## Settings
- **Folder** — where weekly notes are stored (default: `Practice`).
- **Week starts on** — first day of the practice week. Weeks that cross into a new year are stored under the year they started.
- **Default skills** — rows used in each new week's skill table.
## Commands
Available via the Command Palette (`⌘P` / `Ctrl+P`):
- **Open this week's practice plan**
- **Open next week's practice plan**
- **Open previous week's practice plan**
Each command opens the matching week's note, creating it from the template if it doesn't yet exist.
## File layout
Notes are written as:
```
<Folder>/<YYYY>/<YYYY>-Www.md
```
Where `YYYY` is the year of the week's **start** date and `ww` is the week number counted from the first start-day of that year.
## Development
```bash
npm install
npm run dev # watch + rebuild main.js
npm run build # type-check + production build
```
To test in Obsidian, copy or symlink this folder into `<vault>/.obsidian/plugins/practice-planner/`. Obsidian needs `main.js`, `manifest.json`, and `styles.css` at runtime.

46
esbuild.config.mjs Normal file
View file

@ -0,0 +1,46 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: { js: banner },
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

271
main.ts Normal file
View file

@ -0,0 +1,271 @@
import {
App,
Notice,
Plugin,
PluginSettingTab,
Setting,
TFile,
normalizePath,
} from "obsidian";
type DayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
interface PracticePlannerSettings {
folderPath: string;
weekStartDay: DayIndex;
skills: string[];
}
const DEFAULT_SETTINGS: PracticePlannerSettings = {
folderPath: "Practice",
weekStartDay: 1,
skills: ["Scales", "Technique", "Repertoire", "Sight Reading"],
};
const DAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export default class PracticePlannerPlugin extends Plugin {
settings: PracticePlannerSettings = DEFAULT_SETTINGS;
async onload() {
await this.loadSettings();
this.addCommand({
id: "open-this-week",
name: "Open this week's practice plan",
callback: () => this.openWeek(0),
});
this.addCommand({
id: "open-next-week",
name: "Open next week's practice plan",
callback: () => this.openWeek(1),
});
this.addCommand({
id: "open-previous-week",
name: "Open previous week's practice plan",
callback: () => this.openWeek(-1),
});
this.addSettingTab(new PracticePlannerSettingTab(this.app, this));
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
async openWeek(weekOffset: number) {
const today = new Date();
const target = new Date(today);
target.setDate(target.getDate() + weekOffset * 7);
const start = startOfWeek(target, this.settings.weekStartDay);
const { year, week } = weekKey(start, this.settings.weekStartDay);
const path = this.notePathFor(year, week);
let file = this.app.vault.getAbstractFileByPath(path);
if (!file) {
await this.ensureFolder(path);
const body = renderTemplate(
start,
year,
week,
this.settings.weekStartDay,
this.settings.skills,
);
file = await this.app.vault.create(path, body);
}
if (file instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(file);
} else {
new Notice(`Could not open ${path}`);
}
}
notePathFor(year: number, week: number): string {
const weekStr = String(week).padStart(2, "0");
const folder = this.settings.folderPath.replace(/^\/+|\/+$/g, "");
return normalizePath(`${folder}/${year}/${year}-W${weekStr}.md`);
}
async ensureFolder(filePath: string) {
const parts = filePath.split("/");
parts.pop();
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
if (!this.app.vault.getAbstractFileByPath(current)) {
await this.app.vault.createFolder(current);
}
}
}
}
function startOfWeek(date: Date, weekStartDay: DayIndex): Date {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const offset = (d.getDay() - weekStartDay + 7) % 7;
d.setDate(d.getDate() - offset);
return d;
}
function firstWeekStartOfYear(year: number, weekStartDay: DayIndex): Date {
const jan1 = new Date(year, 0, 1);
const offset = (weekStartDay - jan1.getDay() + 7) % 7;
return new Date(year, 0, 1 + offset);
}
function weekKey(
weekStart: Date,
weekStartDay: DayIndex,
): { year: number; week: number } {
const year = weekStart.getFullYear();
const firstStart = firstWeekStartOfYear(year, weekStartDay);
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
const diff = weekStart.getTime() - firstStart.getTime();
const week = Math.floor(diff / msPerWeek) + 1;
return { year, week };
}
function orderedDays(weekStartDay: DayIndex): DayIndex[] {
const out: DayIndex[] = [];
for (let i = 0; i < 7; i++) {
out.push(((weekStartDay + i) % 7) as DayIndex);
}
return out;
}
function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function formatISO(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function renderTemplate(
weekStart: Date,
year: number,
week: number,
weekStartDay: DayIndex,
skills: string[],
): string {
const days = orderedDays(weekStartDay);
const weekEnd = addDays(weekStart, 6);
const weekStr = String(week).padStart(2, "0");
const headerCols = days.map((d) => DAY_SHORT[d]).join(" | ");
const sepCols = days.map(() => "---").join(" | ");
const skillRows = (skills.length ? skills : ["(skill)"])
.map((s) => `| ${s} | ${days.map(() => " ").join(" | ")} |`)
.join("\n");
const dailySections = days
.map((d, i) => {
const date = addDays(weekStart, i);
return `### ${DAY_NAMES[d]}${formatISO(date)}\n- \n`;
})
.join("\n");
return `---
type: practice-week
year: ${year}
week: ${week}
start: ${formatISO(weekStart)}
end: ${formatISO(weekEnd)}
week_start_day: ${DAY_NAMES[weekStartDay]}
---
# Week ${weekStr} of ${year} (${formatISO(weekStart)} ${formatISO(weekEnd)})
## Goals
## Skills
| Skill | ${headerCols} |
| --- | ${sepCols} |
${skillRows}
## Daily Notes
${dailySections}`;
}
class PracticePlannerSettingTab extends PluginSettingTab {
plugin: PracticePlannerPlugin;
constructor(app: App, plugin: PracticePlannerPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Folder")
.setDesc("Vault folder where weekly practice notes are stored.")
.addText((text) =>
text
.setPlaceholder("Practice")
.setValue(this.plugin.settings.folderPath)
.onChange(async (value) => {
this.plugin.settings.folderPath = value || "Practice";
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Week starts on")
.setDesc(
"First day of the practice week. Weeks that cross into a new year are stored under the year they started.",
)
.addDropdown((dd) => {
DAY_NAMES.forEach((name, i) => dd.addOption(String(i), name));
dd.setValue(String(this.plugin.settings.weekStartDay));
dd.onChange(async (value) => {
this.plugin.settings.weekStartDay = Number(value) as DayIndex;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Default skills")
.setDesc("One skill per line. Used as rows in each new week's skill table.")
.addTextArea((ta) => {
ta.setPlaceholder("Scales\nTechnique\nRepertoire\nSight Reading");
ta.setValue(this.plugin.settings.skills.join("\n"));
ta.onChange(async (value) => {
this.plugin.settings.skills = value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
await this.plugin.saveSettings();
});
ta.inputEl.rows = 6;
ta.inputEl.cols = 30;
});
}
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "practice-planner",
"name": "Weekly Music Practice Planner",
"version": "0.1.0",
"minAppVersion": "0.15.0",
"description": "Plan and track weekly music practice: per-skill progress by day, plus per-day notes.",
"author": "Patrick Driggett",
"authorUrl": "",
"isDesktopOnly": false
}

2488
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "practice-planner",
"version": "0.1.0",
"description": "Weekly Music Practice Planner for Obsidian.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"music",
"practice",
"planner"
],
"author": "Patrick Driggett",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.28.1",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

1
styles.css Normal file
View file

@ -0,0 +1 @@
/* Weekly Music Practice Planner styles */

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

12
version-bump.mjs Normal file
View file

@ -0,0 +1,12 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "0.15.0"
}