feat(#62): render task checkboxes as calendar bars + task creation modal (#63)

Co-authored-by: nmn <punkyard@users.noreply.github.com>
This commit is contained in:
pun kyard 2026-07-04 03:45:11 +02:00 committed by GitHub
parent a79f3c4c70
commit cedbe790cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 674 additions and 84 deletions

View file

@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project follows Semantic Versioning.
## [Unreleased]
### Added
- Task bars: vault-wide scan of `- [ ]` lines matching configurable tag + Tasks-plugin emoji date → rendered as bordered outline bars on calendar grid (#62)
- Range bars: tasks with both `🛫` (start) and `📅` (due) dates span across columns (#62)
- Task creation modal: type `/` at line start (EditorSuggest) or Command Palette → modal with title, START/DUE date+time pickers, color picker (#62)
- Fenced code blocks skipped during task scan (#62)
- Configurable task tag in settings (default `#lindar`) (#62)
- `endDate` field on `LindarTask` for range rendering (#62)
### Changed
- Task date priority: `🛫` (start) → `📅` (due) → `⏳` (scheduled) — bar shows on start date when both start and due are present (#62)
- Event modal buttons: Save/Cancel/Delete left-aligned, Open note right-aligned (#62)
## [0.2.2] - 2026-07-03
### Fixed

View file

@ -25,28 +25,44 @@ We'd like to thank him for his approach and his inspiration that guided the visu
| Development guide | [`docs/development.md`](docs/development.md) |
| Roadmap | [`docs/roadmap.md`](docs/roadmap.md) |
| Design notes | [`docs/design.md`](docs/design.md) |
| Day highlights | [`docs/highlights.md`](docs/highlights.md) |
| Categories | [`docs/categories.md`](docs/categories.md) — type labels with default colors, per-event override |
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
## Options
![[options.png]]
## Manage categories
![[options-categories.png]]
---
## Quick start
1. choose your `Events notes folder` in `Settings > Community plugins > Lindar` (default is `yearly-events`)
2. set hotkey for **Lindar: Open**` in `Settings > Hotkeys` (suggested: cmd+Shift+L`)
1. choose your `Events notes folder` in `Settings > Community plugins > Lindar` (default is `Lindar`)
2. set hotkey for **Lindar: Open** in `Settings > Hotkeys` (suggested: `Cmd+Shift+L`)
3. open the `Linear Calendar` tab from the `ribbon` icon or command palette or hotkey
4. click a date (soon drag across dates) to create an event
4. click a date to create an event
5. fill in `TITLE`, date range, color, and optional metadata (`TYPE`, `PARTICIPANTS`, `DESCRIPTION`)
6. on `Save`: the event is written as a Markdown note in your configured events folder
7. `cmd+click`an event to open its related local note
7. `cmd+click` an event to open its related local note
8. add `- [ ] Task #lindar 📅 YYYY-MM-DD \`#hex\`` to any note to render a task bar on the calendar
![](assets/event-details.png)
![event](assets/event-details.png)
![[event-note.png]]
![note](assets/event-note.png)
## Quick task
Task checkboxes from any vault note appear as bordered outline bars in the calendar.
```
- [ ] Title #lindar 🛫 YYYY-MM-DD 📅 YYYY-MM-DD `#hex`
```
- Tag filter configurable in settings (default `#lindar`)
- Date emoji: `🛫` (start) or `📅` (due) or `⏳` (scheduled) — Tasks plugin compatible
- Color: `` `#hex` `` in backticks at end of line
- Range bars when both start and due are present
### Create a task
- **Type `/` at line start** in any note → pick "Lindar: Create task" → fill the modal
- Or use **Command Palette**`Lindar: Create task at cursor`
---
### ⭐️ **Roadmap** unlocks with GitHub stars:

View file

@ -1,41 +0,0 @@
# Categories
## What they are
Categories let you label events by type and automatically assign a default color. Each category has a name and a color.
When you pick a category in the event creation modal, the color picker auto-fills with that category's color. You can still override the color per-event — the category color is a default, not a constraint.
## Managing categories
Go to **Settings → Community plugins → Lindar**.
Below the existing settings, after a horizontal divider, you'll find the **Categories** section:
- **Add**: click the **+ Add category** button, enter a name, and pick a color.
- **Edit**: type directly in the name field or click the color swatch.
- **Delete**: click the trash icon on any category row. This cannot be undone.
All changes save immediately.
## Default seed categories
| Category | Default color |
|---|---|
| appointment | `#4f46e5` |
| call | `#0891b2` |
| meal | `#65a30d` |
| meeting | `#ca8a04` |
| travel | `#ea580c` |
| deadline | `#dc2626` |
| task | `#7c3aed` |
| reminder | `#0d9488` |
| other | `#78716c` |
You can add, rename, recolor, or remove any of these — they are just a starting point.
## Per-event color override
1. Type or select a category name in the event modal's **Type** field.
2. The **Color** picker auto-fills with that category's color.
3. If you want a different color for this specific event, change the color picker — your change is per-event and does not affect the category definition.

View file

@ -88,6 +88,91 @@ Optional notes about the event...
Existing older event notes without `event`, `type`, or `participants` remain compatible.
## Categories
Categories let you label events by type and auto-assign a default color.
When you pick a category in the event creation modal, the **Color** picker auto-fills with that category's color. You can still override the color per-event — the category color is a default, not a constraint.
Categories are managed in **Settings → Community plugins → Lindar**, after the horizontal divider:
- **Add** — click **+ Add category**, enter a name, pick a color.
- **Reorder** — ↑ and ↓ buttons per row.
- **Enable/disable** — toggle off hides the category from the modal's type dropdown.
- **Delete** — trash icon. Irreversible.
All changes save immediately.
See [`docs/options.md`](docs/options.md#categories) for the full seed list and UI reference.
## Task bars
Task checkboxes from any vault note can appear as bordered outline bars in the calendar.
### Line format
```
- [ ] Title #tag 📅 YYYY-MM-DD `#hex`
```
### How it works
1. Lindar scans every `.md` file for unchecked task lines (`- [ ]`).
2. Lines must contain the configured **task tag** (default `#lindar`) and at least one Tasks-plugin-compatible emoji date.
3. Date priority: `📅|📆|🗓` (due) → `⏳|⌛` (scheduled) → `🛫` (start).
4. Optional `` `#hexcode` `` at end of line sets the bar color (default `#4f46e5`).
5. Multiple tasks on the same day stack into separate lanes.
### Example
```
- [ ] Buy groceries #lindar 📅 2026-07-03 `#e11d48`
- [ ] Call dentist #lindar ⏳ 2026-07-05
- [ ] Plan trip #lindar 🛫 2026-08-01 `#0891b2`
```
### Interaction
- Click a task bar → opens the source note with the cursor placed on that task line.
- Vault changes (create/modify/delete) trigger a debounced re-scan (2 s).
### Settings
| Setting | Default | Description |
|---|---|---|
| Task tag | `#lindar` | Tag filter for task lines |
## Task creation
Lindar provides two ways to create a task line directly in any note:
- **Type `/` at line start** → EditorSuggest popup "Lindar: Create task" → select to open the task modal
- **Command Palette**: `Lindar: Create task at cursor`
### Modal fields
| Field | Detail |
|---|---|
| Title | Text input, required |
| START | Optional date + time picker (`--:--` = no time) |
| DUE | Optional date + time picker (`--:--` = no time) |
| Color | Native OS color picker |
| Tag | Auto-applied from settings (default `#lindar`), not shown |
### Output format
```
- [ ] Title 🛫 YYYY-MM-DD HH:mm 📅 YYYY-MM-DD HH:mm `#hex` #tag
```
- Time, start, due, and color omitted when unset.
- Tag always appended from settings.
- Inserted on the current line if empty, or the next empty line; appended at EOF if none found.
### Line format reference
See the [Task bars](#task-bars) section above for the full syntax reference.
## Compatibility notes
- Older event notes continue loading even if they only have legacy fields.

View file

@ -1,17 +0,0 @@
# Day highlights
Lindar provides visual highlights for weekends and Wednesdays to help you navigate the calendar at a glance.
## Toggles
Both highlights are enabled by default. You can disable them in **Settings → Community plugins → Lindar**:
| Setting | Default | Description |
|---|---|---|
| Highlight weekends | On | Applies weekend styling to Saturday and Sunday cells |
| Highlight Wednesdays | On | Applies a muted highlight to Wednesday cells |
## Location
- **Settings → Community plugins → Lindar**
- Located after the **Motto** field and before **Expand months to fit events**

View file

@ -5,7 +5,7 @@ All settings are in **Settings → Community plugins → Lindar**.
## Event notes folder
| | |
|---|---|
|---|---|---|
| Setting | Event notes folder |
| Default | `Lindar` |
| Description | Folder used to store calendar event notes. Relative to vault root. Created automatically if it doesn't exist. |
@ -13,11 +13,30 @@ All settings are in **Settings → Community plugins → Lindar**.
## Default event color
| | |
|---|---|
|---|---|---|
| Setting | Default event color |
| Default | `#4f46e5` |
| Description | Used as the default color when creating events. A **reset** button restores the default. |
## Task tag
| | |
|---|---|
| Setting | Task tag |
| Default | `#lindar` |
| Description | Tag used to identify task checkbox lines for calendar display. Any `- [ ]` line containing this tag and a Tasks-plugin-compatible emoji date (📅📆🗓⏳⌛🛫) is rendered as a bordered outline bar in the grid. Color can be set per task with a backtick-wrapped `#hex` at end of line. |
### Task line format
```
- [ ] Title #tag 📅 YYYY-MM-DD `#hex`
```
- Tag: configurable (default `#lindar`)
- Date priority: `📅|📆|🗓` (due) > `⏳|⌛` (scheduled) > `🛫` (start)
- Color: `` `#hexcode` `` in backticks at end of line
- Clicking a task bar opens the source note at the task line
## Motto
| | |
@ -33,6 +52,8 @@ All settings are in **Settings → Community plugins → Lindar**.
| Highlight Wednesdays | On | Applies a muted highlight to Wednesday cells. |
| Highlight weekends | On | Applies weekend styling to Saturday and Sunday cells. |
Located after the **Motto** field and before **Expand months to fit events**.
## Event lanes
| Setting | Default | Description |

111
src/events/task-store.ts Normal file
View file

@ -0,0 +1,111 @@
import { App, TFile } from "obsidian";
import type { LindarTask } from "../types";
const TASK_LINE_RE = /^- \[ \]\s+(.+)$/;
const FENCE_RE = /^(```|~~~)/;
const DUE_DATE_RE = /(?:📅|📆|🗓)\uFE0F? *(\d{4}-\d{2}-\d{2})(?!\S)/;
const SCHEDULED_DATE_RE = /(?:⏳|⌛)\uFE0F? *(\d{4}-\d{2}-\d{2})(?!\S)/;
const START_DATE_RE = /🛫\uFE0F? *(\d{4}-\d{2}-\d{2})(?!\S)/;
const COLOR_RE = /`#([0-9a-fA-F]{6})`/;
export async function loadTasks(app: App, taskTag: string): Promise<LindarTask[]> {
const files = app.vault.getMarkdownFiles();
const tasks: LindarTask[] = [];
for (const file of files) {
const content = await app.vault.cachedRead(file);
const lines = content.split("\n");
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
if (FENCE_RE.test(line.trim())) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const taskMatch = line.match(TASK_LINE_RE);
if (!taskMatch) continue;
const task = parseTaskLine(taskMatch[1]!, file, i, taskTag);
if (task) tasks.push(task);
}
}
return tasks;
}
function stripMatch(s: string, match: RegExpMatchArray): string {
return (s.slice(0, match.index) + s.slice(match.index! + match[0].length)).trim();
}
function parseTaskLine(text: string, file: TFile, lineIndex: number, taskTag: string): LindarTask | null {
let rest = text.trim();
let color = "#4f46e5";
const colorMatch = rest.match(COLOR_RE);
if (colorMatch) {
color = `#${colorMatch[1]}`;
rest = stripMatch(rest, colorMatch);
}
if (!rest.includes(taskTag)) return null;
rest = rest.replace(new RegExp(`\\s*${escapeRegex(taskTag)}\\s*`, "g"), " ").trim();
const startMatch = rest.match(START_DATE_RE);
const dueMatch = rest.match(DUE_DATE_RE);
const scheduledMatch = rest.match(SCHEDULED_DATE_RE);
if (startMatch && dueMatch) {
let title = stripMatch(rest, startMatch);
title = stripMatch(title, title.match(DUE_DATE_RE)!);
return {
title: title || "Untitled",
date: startMatch[1]!,
endDate: dueMatch[1]!,
color,
filePath: file.path,
line: lineIndex,
};
}
if (startMatch) {
return {
title: stripMatch(rest, startMatch) || "Untitled",
date: startMatch[1]!,
color,
filePath: file.path,
line: lineIndex,
};
}
if (dueMatch) {
return {
title: stripMatch(rest, dueMatch) || "Untitled",
date: dueMatch[1]!,
color,
filePath: file.path,
line: lineIndex,
};
}
if (scheduledMatch) {
return {
title: stripMatch(rest, scheduledMatch) || "Untitled",
date: scheduledMatch[1]!,
color,
filePath: file.path,
line: lineIndex,
};
}
return null;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -1,17 +1,20 @@
import { Plugin, WorkspaceLeaf } from "obsidian";
import { Plugin, TFile, WorkspaceLeaf } from "obsidian";
import { DEFAULT_SETTINGS, LinearCalendarSettingTab } from "./settings";
import type { LinearCalendarSettings } from "./types";
import { LinearCalendarYearView, VIEW_TYPE_LINEAR_CALENDAR } from "./ui/year-view";
import { CreateTaskModal } from "./ui/create-task-modal";
import { LindarSuggest } from "./ui/lindar-suggest";
export default class LinearCalendarPlugin extends Plugin {
settings!: LinearCalendarSettings;
private taskRefreshTimer: number | null = null;
async onload(): Promise<void> {
await this.loadSettings();
this.registerView(VIEW_TYPE_LINEAR_CALENDAR, (leaf) => new LinearCalendarYearView(leaf, this));
this.addRibbonIcon("calendar", "Lindar", () => {
this.addRibbonIcon("calendar", "Lindar", () => {
void this.activateView();
});
@ -23,11 +26,53 @@ export default class LinearCalendarPlugin extends Plugin {
},
});
this.addCommand({
id: "create-task",
name: "Create task at cursor",
editorCallback: (editor) => {
new CreateTaskModal(this.app, editor, this).open();
},
});
this.registerEditorSuggest(new LindarSuggest(this.app, this));
this.addSettingTab(new LinearCalendarSettingTab(this.app, this));
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceof TFile && file.extension === "md") {
this.scheduleTaskRefresh();
}
})
);
this.registerEvent(
this.app.vault.on("create", (file) => {
if (file instanceof TFile && file.extension === "md") {
this.scheduleTaskRefresh();
}
})
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
if (file instanceof TFile && file.extension === "md") {
this.scheduleTaskRefresh();
}
})
);
}
onunload(): void {}
private scheduleTaskRefresh(): void {
if (this.taskRefreshTimer !== null) {
window.clearTimeout(this.taskRefreshTimer);
}
this.taskRefreshTimer = window.setTimeout(() => {
this.taskRefreshTimer = null;
this.refreshCalendar();
}, 2000);
}
async activateView(): Promise<void> {
const { workspace } = this.app;

View file

@ -12,6 +12,7 @@ export const DEFAULT_SETTINGS: LinearCalendarSettings = {
adaptMonthLanesToEvents: false,
highlightWeekends: true,
highlightWednesdays: true,
taskTag: "#lindar",
categories: [
{ name: "appointment", color: "#4f46e5", enabled: true },
{ name: "call", color: "#0891b2", enabled: true },
@ -110,6 +111,20 @@ export class LinearCalendarSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName("Task tag")
.setDesc("Tag used to identify tasks for calendar display.")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.taskTag)
.setValue(this.plugin.settings.taskTag)
.onChange(async (value) => {
this.plugin.settings.taskTag = value.trim() || DEFAULT_SETTINGS.taskTag;
await this.plugin.saveSettings();
this.plugin.refreshCalendar();
})
);
let lanesSetting: Setting;
new Setting(containerEl)

View file

@ -16,6 +16,15 @@ export interface LinearCalendarEvent {
filePath?: string;
}
export interface LindarTask {
title: string;
date: string;
endDate?: string;
color: string;
filePath: string;
line: number;
}
export interface LinearCalendarSettings {
eventsFolder: string;
defaultColor: string;
@ -25,4 +34,5 @@ export interface LinearCalendarSettings {
highlightWeekends: boolean;
highlightWednesdays: boolean;
categories: EventCategory[];
taskTag: string;
}

View file

@ -6,7 +6,7 @@
import { getContrastTextColor, getEventColorTone } from "../utils/colors";
import { getDaysInMonth, getFirstDayOfWeek, isToday, isWeekend, isWednesday, getWeekdayLabels, getWeekdayLabelsMobile, getMonthNameShort, getMonthNameShortMobile, getWeekNumber, isMonday } from "../utils/dates";
import type { LinearCalendarEvent } from "../types";
import type { LinearCalendarEvent, LindarTask } from "../types";
const MAX_COLS = 37; // 6 (max offset) + 31 (max days in month)
const MONTH_BASE_HEIGHT = 24;
@ -26,9 +26,11 @@ export function renderCalendar(
container: HTMLElement,
year: number,
events: LinearCalendarEvent[],
tasks: LindarTask[],
onDateClick?: (dateStr: string) => void,
onEventClick?: (event: LinearCalendarEvent) => void,
onEventCmdClick?: (event: LinearCalendarEvent) => void,
onTaskClick?: (task: LindarTask) => void,
options?: CalendarLayoutOptions
): void {
container.empty();
@ -59,7 +61,7 @@ export function renderCalendar(
// 12 month rows
for (let month = 1; month <= 12; month++) {
renderMonthRow(monthsGrid, year, month, events, layoutOptions, onDateClick, onEventClick, onEventCmdClick);
renderMonthRow(monthsGrid, year, month, events, tasks, layoutOptions, onDateClick, onEventClick, onEventCmdClick, onTaskClick);
}
// Bottom weekday row (repeated day line)
@ -95,10 +97,12 @@ function renderMonthRow(
year: number,
month: number,
events: LinearCalendarEvent[],
tasks: LindarTask[],
layoutOptions: CalendarLayoutOptions,
onDateClick?: (dateStr: string) => void,
onEventClick?: (event: LinearCalendarEvent) => void,
onEventCmdClick?: (event: LinearCalendarEvent) => void
onEventCmdClick?: (event: LinearCalendarEvent) => void,
onTaskClick?: (task: LindarTask) => void
): void {
const monthRow = wrapper.createDiv("linear-calendar-month-row");
@ -166,10 +170,17 @@ function renderMonthRow(
const {
eventsLayer,
totalLanes,
totalLanes: eventLanes,
eventSpanStartCol,
eventSpanEndCol,
} = renderMonthEventBars(monthRow, year, month, daysInMonth, firstDayOffset, events, onEventClick, onEventCmdClick);
let taskLanes = 0;
if (eventsLayer && tasks.length > 0) {
taskLanes = renderMonthTaskBars(eventsLayer, year, month, daysInMonth, firstDayOffset, tasks, eventLanes, onTaskClick);
}
const totalLanes = eventLanes + taskLanes;
const visibleLanes = getVisibleLanes(totalLanes, layoutOptions);
const rowHeightLanes = getRowHeightLanes(visibleLanes, totalLanes, layoutOptions);
applyMonthRowLayout(monthRow, visibleLanes, rowHeightLanes, totalLanes, layoutOptions);
@ -334,6 +345,74 @@ function renderMonthEventBars(
};
}
function renderMonthTaskBars(
eventsLayer: HTMLElement,
year: number,
month: number,
daysInMonth: number,
firstDayOffset: number,
tasks: LindarTask[],
startLaneIndex: number,
onTaskClick?: (task: LindarTask) => void
): number {
const monthStart = `${year}-${String(month).padStart(2, "0")}-01`;
const monthEnd = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
const monthTasks = tasks
.filter((t) => {
const taskEnd = t.endDate ?? t.date;
return t.date <= monthEnd && taskEnd >= monthStart;
})
.sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
const aEnd = a.endDate ?? a.date;
const bEnd = b.endDate ?? b.date;
return aEnd.localeCompare(bEnd);
});
if (monthTasks.length === 0) return 0;
const laneLastEndCol: number[] = [];
for (const task of monthTasks) {
const taskEnd = task.endDate ?? task.date;
const visibleStart = task.date < monthStart ? monthStart : task.date;
const visibleEnd = taskEnd > monthEnd ? monthEnd : taskEnd;
const startDay = dateDayInMonth(visibleStart);
const endDay = dateDayInMonth(visibleEnd);
if (!Number.isFinite(startDay) || !Number.isFinite(endDay) || endDay < startDay) continue;
const startCol = firstDayOffset + startDay;
const endColExclusive = firstDayOffset + endDay + 1;
let laneIndex = laneLastEndCol.findIndex((lastEndCol) => startCol > lastEndCol);
if (laneIndex === -1) {
laneIndex = laneLastEndCol.length;
laneLastEndCol.push(endColExclusive - 1);
} else {
laneLastEndCol[laneIndex] = endColExclusive - 1;
}
const bar = eventsLayer.createDiv("linear-calendar-task-bar");
bar.setText(task.title);
bar.style.setProperty("--task-color", task.color);
bar.style.gridColumn = `${startCol} / ${endColExclusive}`;
bar.style.gridRow = String(startLaneIndex + laneIndex + 1);
bar.setAttribute("title", task.title);
if (task.date < monthStart) bar.addClass("linear-calendar-event-continues-left");
if (taskEnd > monthEnd) bar.addClass("linear-calendar-event-continues-right");
bar.addEventListener("click", (e) => {
e.stopPropagation();
onTaskClick?.(task);
});
}
return laneLastEndCol.length;
}
function toggleLinkedEventBarHoverState(sourceBar: HTMLElement, eventId: string, isHovered: boolean): void {
if (!eventId) return;

127
src/ui/create-task-modal.ts Normal file
View file

@ -0,0 +1,127 @@
import { App, Modal, Setting } from "obsidian";
import type { Editor } from "obsidian";
import type LinearCalendarPlugin from "../main";
export class CreateTaskModal extends Modal {
private title = "";
private startDate = "";
private startTime = "--:--";
private dueDate = "";
private dueTime = "--:--";
private color = "#4f46e5";
constructor(
app: App,
private editor: Editor,
private plugin: LinearCalendarPlugin,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: "New task" });
new Setting(contentEl)
.setName("Title")
.addText((t) =>
t.setPlaceholder("Task description").onChange((v) => (this.title = v)),
);
new Setting(contentEl)
.setName("START")
.setDesc("Optional start date")
.addText((t) => {
t.inputEl.type = "date";
t.onChange((v) => (this.startDate = v));
})
.addText((t) => {
t.inputEl.type = "time";
t.setValue("--:--");
t.onChange((v) => (this.startTime = v));
});
new Setting(contentEl)
.setName("DUE")
.setDesc("Optional due date")
.addText((t) => {
t.inputEl.type = "date";
t.onChange((v) => (this.dueDate = v));
})
.addText((t) => {
t.inputEl.type = "time";
t.setValue("--:--");
t.onChange((v) => (this.dueTime = v));
});
new Setting(contentEl)
.setName("Color")
.addText((t) => {
t.inputEl.type = "color";
t.setValue(this.color);
t.onChange((v) => (this.color = v));
});
new Setting(contentEl).addButton((b) =>
b.setButtonText("Create").setCta().onClick(() => this.submit()),
);
}
onClose(): void {
this.contentEl.empty();
}
private submit(): void {
const title = this.title.trim();
if (!title) return;
const parts = [`- [ ] ${title}`];
if (this.startDate) {
const time = this.startTime !== "--:--" ? ` ${this.startTime}` : "";
parts.push(`🛫 ${this.startDate}${time}`);
}
if (this.dueDate) {
const time = this.dueTime !== "--:--" ? ` ${this.dueTime}` : "";
parts.push(`📅 ${this.dueDate}${time}`);
}
if (this.color !== "#4f46e5") {
parts.push(`\`${this.color}\``);
}
parts.push(this.plugin.settings.taskTag);
const taskLine = parts.join(" ");
this.insertAtTargetLine(taskLine);
this.close();
}
private insertAtTargetLine(taskLine: string): void {
const cursor = this.editor.getCursor();
const lineCount = this.editor.lineCount();
let targetLine = cursor.line;
if (this.editor.getLine(targetLine).trim() !== "") {
while (targetLine < lineCount) {
if (this.editor.getLine(targetLine).trim() === "") break;
targetLine++;
}
}
if (targetLine >= lineCount) {
const lastIdx = lineCount - 1;
const lastContent = this.editor.getLine(lastIdx);
this.editor.replaceRange("\n" + taskLine, {
line: lastIdx,
ch: lastContent.length,
});
this.editor.setCursor({ line: lastIdx + 1, ch: taskLine.length });
} else {
this.editor.setLine(targetLine, taskLine);
this.editor.setCursor({ line: targetLine, ch: taskLine.length });
}
}
}

View file

@ -288,20 +288,22 @@ export class EventModal extends Modal {
// Buttons
const buttons = form.createDiv("linear-calendar-modal-buttons");
const saveBtn = buttons.createEl("button", {
const leftGroup = buttons.createDiv("linear-calendar-modal-buttons-left");
const saveBtn = leftGroup.createEl("button", {
text: this.existingEvent ? "Save" : "Create",
attr: { type: "submit" },
});
saveBtn.addClass("mod-cta");
const cancelBtn = buttons.createEl("button", {
const cancelBtn = leftGroup.createEl("button", {
text: "Cancel",
attr: { type: "button" },
});
cancelBtn.onclick = () => this.close();
if (this.existingEvent && this.onDelete) {
const deleteBtn = buttons.createEl("button", {
const deleteBtn = leftGroup.createEl("button", {
text: "Delete",
attr: { type: "button" },
});
@ -313,7 +315,8 @@ export class EventModal extends Modal {
}
if (this.existingEvent) {
const openBtn = buttons.createEl("button", {
const rightGroup = buttons.createDiv("linear-calendar-modal-buttons-right");
const openBtn = rightGroup.createEl("button", {
text: "Open note",
attr: { type: "button" },
});

56
src/ui/lindar-suggest.ts Normal file
View file

@ -0,0 +1,56 @@
import {
App,
Editor,
EditorPosition,
EditorSuggest,
EditorSuggestContext,
TFile,
} from "obsidian";
import type LinearCalendarPlugin from "../main";
import { CreateTaskModal } from "./create-task-modal";
export class LindarSuggest extends EditorSuggest<string> {
private activeEditor: Editor | null = null;
constructor(
app: App,
private plugin: LinearCalendarPlugin,
) {
super(app);
}
onTrigger(
cursor: EditorPosition,
editor: Editor,
_file: TFile,
): { start: EditorPosition; end: EditorPosition; query: string } | null {
this.activeEditor = editor;
const line = editor.getLine(cursor.line);
const upToCursor = line.slice(0, cursor.ch);
const match = upToCursor.match(/^(\s*)\/$/);
if (!match) return null;
return {
start: { line: cursor.line, ch: match[1]!.length },
end: cursor,
query: "",
};
}
getSuggestions(_context: EditorSuggestContext): string[] {
return ["Lindar: Create task"];
}
renderSuggestion(suggestion: string, el: HTMLElement): void {
el.setText(suggestion);
}
selectSuggestion(
_suggestion: string,
_event: KeyboardEvent | MouseEvent,
): void {
const { start, end } = this.context!;
this.activeEditor!.replaceRange("", start, end);
new CreateTaskModal(this.app, this.activeEditor!, this.plugin).open();
}
}

View file

@ -1,9 +1,10 @@
import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
import { ItemView, MarkdownView, TFile, WorkspaceLeaf } from "obsidian";
import type LinearCalendarPlugin from "../main";
import { renderCalendar } from "./calendar-renderer";
import { EventModal } from "./event-modal";
import { deleteEvent, generateEventId, loadEvents, saveEvent, updateEvent } from "../events/event-store";
import type { LinearCalendarEvent } from "../types";
import { loadTasks } from "../events/task-store";
import type { LinearCalendarEvent, LindarTask } from "../types";
import { getContrastTextColor } from "../utils/colors";
export const VIEW_TYPE_LINEAR_CALENDAR = "linear-view";
@ -16,6 +17,7 @@ export class LinearCalendarYearView extends ItemView {
private yearDropdown: HTMLElement | null = null;
private removeYearDropdownListener: (() => void) | null = null;
private eventsCache: LinearCalendarEvent[] | null = null;
private tasksCache: LindarTask[] | null = null;
private navBar: HTMLElement | null = null;
private renderToken = 0;
private pendingRenderFrame: number | null = null;
@ -258,10 +260,14 @@ export class LinearCalendarYearView extends ItemView {
if (forceReload || !this.eventsCache) {
this.eventsCache = await loadEvents(this.plugin.app, this.plugin.settings.eventsFolder);
}
if (forceReload || !this.tasksCache) {
this.tasksCache = await loadTasks(this.plugin.app, this.plugin.settings.taskTag);
}
if (token !== this.renderToken) return;
const events = this.eventsCache;
const tasks = this.tasksCache;
if (!events) return;
container.style.setProperty("--linear-calendar-today-color", this.plugin.settings.defaultColor);
@ -272,9 +278,11 @@ export class LinearCalendarYearView extends ItemView {
container,
this.currentYear,
events,
tasks ?? [],
(dateStr) => this.openCreateEventModal(dateStr),
(event) => { void this.openEditEventModal(event); },
(event) => this.openEventNote(event),
(task) => this.openTaskNote(task),
{
maxVisibleEventLanes: this.plugin.settings.maxVisibleEventLanes,
adaptMonthLanesToEvents: this.plugin.settings.adaptMonthLanesToEvents,
@ -343,6 +351,28 @@ export class LinearCalendarYearView extends ItemView {
}
}
private async openTaskNote(task: LindarTask): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) return;
const leaf = this.app.workspace.getLeaf();
await leaf.openFile(file);
this.scrollEditorToLine(leaf, task.line);
}
private scrollEditorToLine(leaf: WorkspaceLeaf, line: number): void {
const view = leaf.view;
if (!view || !("editor" in view)) return;
const editor = (view as MarkdownView).editor;
const ch = editor.getLine(line).length;
editor.setCursor({ line, ch });
requestAnimationFrame(() => {
editor.scrollIntoView(
{ from: { line, ch }, to: { line, ch } },
true
);
});
}
private async openEditEventModal(event: LinearCalendarEvent): Promise<void> {
const freshEvents = await loadEvents(this.plugin.app, this.plugin.settings.eventsFolder);
this.eventsCache = freshEvents;

View file

@ -458,6 +458,32 @@
padding-right: 4px;
}
/* Task bars — bordered outline */
.linear-calendar-task-bar {
align-self: stretch;
height: var(--linear-calendar-event-lane-height);
padding: 0 4px;
border-radius: 6px;
background: transparent;
border: 1.5px solid var(--task-color);
color: var(--task-color);
font-size: 0.58rem;
font-weight: 600;
line-height: calc(var(--linear-calendar-event-lane-height) - 3px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
pointer-events: auto;
min-width: 0;
transition: background-color 0.14s ease, box-shadow 0.14s ease;
}
.linear-calendar-task-bar:hover {
background-color: color-mix(in srgb, var(--task-color) 15%, transparent);
box-shadow: 0 0 0 1px var(--task-color);
}
/* Event modal */
.linear-calendar-event-modal .linear-calendar-modal-field {
display: flex;
@ -505,11 +531,21 @@
.linear-calendar-event-modal .linear-calendar-modal-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
justify-content: space-between;
margin-top: 16px;
}
.linear-calendar-event-modal .linear-calendar-modal-buttons-left {
display: flex;
gap: 8px;
}
.linear-calendar-event-modal .linear-calendar-modal-buttons-right {
display: flex;
gap: 8px;
align-items: center;
}
.linear-calendar-event-modal .linear-calendar-delete-btn {
border-color: var(--color-red);
color: var(--color-red);