From cedbe790cd792de4e5142df5f49ec6b4b0f81e31 Mon Sep 17 00:00:00 2001 From: pun kyard <59349105+punkyard@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:45:11 +0200 Subject: [PATCH] feat(#62): render task checkboxes as calendar bars + task creation modal (#63) Co-authored-by: nmn --- CHANGELOG.md | 14 ++++ README.md | 36 +++++++--- docs/categories.md | 41 ------------ docs/features.md | 85 ++++++++++++++++++++++++ docs/highlights.md | 17 ----- docs/options.md | 25 ++++++- src/events/task-store.ts | 111 +++++++++++++++++++++++++++++++ src/main.ts | 49 +++++++++++++- src/settings.ts | 15 +++++ src/types.ts | 10 +++ src/ui/calendar-renderer.ts | 87 ++++++++++++++++++++++-- src/ui/create-task-modal.ts | 127 ++++++++++++++++++++++++++++++++++++ src/ui/event-modal.ts | 11 ++-- src/ui/lindar-suggest.ts | 56 ++++++++++++++++ src/ui/year-view.ts | 34 +++++++++- styles.css | 40 +++++++++++- 16 files changed, 674 insertions(+), 84 deletions(-) delete mode 100644 docs/categories.md delete mode 100644 docs/highlights.md create mode 100644 src/events/task-store.ts create mode 100644 src/ui/create-task-modal.ts create mode 100644 src/ui/lindar-suggest.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 165df9d..9ba1c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 51ade8e..599ae22 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/categories.md b/docs/categories.md deleted file mode 100644 index 7fbc545..0000000 --- a/docs/categories.md +++ /dev/null @@ -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. diff --git a/docs/features.md b/docs/features.md index f226d6e..f88766f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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. diff --git a/docs/highlights.md b/docs/highlights.md deleted file mode 100644 index 1aa794e..0000000 --- a/docs/highlights.md +++ /dev/null @@ -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** diff --git a/docs/options.md b/docs/options.md index c1cbbc8..6f79ce5 100644 --- a/docs/options.md +++ b/docs/options.md @@ -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 | diff --git a/src/events/task-store.ts b/src/events/task-store.ts new file mode 100644 index 0000000..d7a333d --- /dev/null +++ b/src/events/task-store.ts @@ -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 { + 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, "\\$&"); +} diff --git a/src/main.ts b/src/main.ts index a6a6480..43eafdb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { 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 { const { workspace } = this.app; diff --git a/src/settings.ts b/src/settings.ts index f8ea92f..11cf4a4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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) diff --git a/src/types.ts b/src/types.ts index 4d9e856..2f42486 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/src/ui/calendar-renderer.ts b/src/ui/calendar-renderer.ts index a13e93a..9fa6e1c 100644 --- a/src/ui/calendar-renderer.ts +++ b/src/ui/calendar-renderer.ts @@ -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; diff --git a/src/ui/create-task-modal.ts b/src/ui/create-task-modal.ts new file mode 100644 index 0000000..47b8487 --- /dev/null +++ b/src/ui/create-task-modal.ts @@ -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 }); + } + } +} diff --git a/src/ui/event-modal.ts b/src/ui/event-modal.ts index cbe9faf..b4f6a50 100644 --- a/src/ui/event-modal.ts +++ b/src/ui/event-modal.ts @@ -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" }, }); diff --git a/src/ui/lindar-suggest.ts b/src/ui/lindar-suggest.ts new file mode 100644 index 0000000..c2731fb --- /dev/null +++ b/src/ui/lindar-suggest.ts @@ -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 { + 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(); + } +} diff --git a/src/ui/year-view.ts b/src/ui/year-view.ts index eb0b3cc..d312cce 100644 --- a/src/ui/year-view.ts +++ b/src/ui/year-view.ts @@ -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 { + 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 { const freshEvents = await loadEvents(this.plugin.app, this.plugin.settings.eventsFolder); this.eventsCache = freshEvents; diff --git a/styles.css b/styles.css index ba1b249..4bf0c16 100644 --- a/styles.css +++ b/styles.css @@ -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);