Release v0.2.0: Task comments, recurring calendar, system tray, app branding

New features:
- Task comments with date-stamped entries and last-comment summary
- Recurring tasks expanded on calendar (daily/weekly/monthly/yearly)
- System tray mode replacing CMD window (Windows/macOS/Linux)
- Ironpad logo as exe icon, tray icon, favicon, and header logo

Technical changes:
- Backend restructured for dual-mode: dev (API-only) / prod (tray + server)
- tray-item crate for cross-platform tray, winresource for icon embedding
- Calendar view refactored with CalendarEntry interface for recurring merging
- Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-16 13:48:54 +01:00
parent b150a243fd
commit 781ea28097
29 changed files with 1735 additions and 219 deletions

View File

@@ -6,6 +6,8 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="/logo-180.png" />
<title>Ironpad</title>
<script>
// Apply saved theme immediately to prevent flash

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
frontend/public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -15,7 +15,8 @@ import type {
CommitDetail,
DiffInfo,
RemoteInfo,
DailyNote
DailyNote,
Comment
} from '../types'
const API_BASE = '/api'
@@ -150,6 +151,20 @@ export const tasksApi = {
delete: (projectId: string, taskId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'DELETE'
}),
// Add a comment to a task
addComment: (projectId: string, taskId: string, text: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
}),
// Delete a comment from a task by index
deleteComment: (projectId: string, taskId: string, commentIndex: number) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments/${commentIndex}`, {
method: 'DELETE'
})
}

View File

@@ -65,7 +65,10 @@ function goHome() {
<template>
<header class="topbar">
<div class="topbar-left">
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1>
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">
<img src="/logo-32.png" alt="" class="app-logo" />
Ironpad
</h1>
<div class="project-selector">
<button class="project-button" @click="toggleDropdown">
@@ -175,6 +178,15 @@ function goHome() {
font-weight: 600;
margin: 0;
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.app-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.project-selector {

View File

@@ -181,6 +181,42 @@ export const useTasksStore = defineStore('tasks', () => {
}
}
async function addComment(projectId: string, taskId: string, text: string) {
try {
error.value = null
const task = await tasksApi.addComment(projectId, taskId, text)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to add comment: ${err}`
throw err
}
}
async function deleteComment(projectId: string, taskId: string, commentIndex: number) {
try {
error.value = null
const task = await tasksApi.deleteComment(projectId, taskId, commentIndex)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to delete comment: ${err}`
throw err
}
}
function selectTask(task: Task | null) {
if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id)
@@ -227,6 +263,8 @@ export const useTasksStore = defineStore('tasks', () => {
toggleTask,
updateTaskMeta,
deleteTask,
addComment,
deleteComment,
selectTask,
clearSelectedTask,
clearProjectTasks,

View File

@@ -40,6 +40,11 @@ export interface ProjectNoteWithContent extends ProjectNote {
content: string
}
export interface Comment {
date: string
text: string
}
export interface Task {
id: string
title: string
@@ -56,10 +61,12 @@ export interface Task {
path: string
created: string
updated: string
last_comment?: string
}
export interface TaskWithContent extends Task {
content: string
comments: Comment[]
}
export interface SearchResult {

View File

@@ -93,21 +93,144 @@ function formatDate(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
// Tasks grouped by due date
// Calendar entry: a task plus whether it's a recurring occurrence (vs a real due_date)
interface CalendarEntry {
task: Task
isRecurring: boolean
}
// Tasks grouped by due date (regular, non-recurring placements)
const tasksByDate = computed(() => {
const map = new Map<string, Task[]>()
const map = new Map<string, CalendarEntry[]>()
for (const task of tasksStore.allTasks) {
if (task.due_date && !task.completed) {
const existing = map.get(task.due_date) || []
existing.push(task)
existing.push({ task, isRecurring: false })
map.set(task.due_date, existing)
}
}
return map
})
function getTasksForDate(dateStr: string): Task[] {
return tasksByDate.value.get(dateStr) || []
// Expand recurring tasks into the visible calendar range
const recurringByDate = computed(() => {
const map = new Map<string, CalendarEntry[]>()
const days = calendarDays.value
if (days.length === 0) return map
const rangeStart = days[0].date
const rangeEnd = days[days.length - 1].date
for (const task of tasksStore.allTasks) {
if (task.completed || !task.recurrence) continue
const interval = task.recurrence_interval || 1
const anchorStr = task.due_date || task.created?.split('T')[0]
if (!anchorStr) continue
const anchor = new Date(anchorStr + 'T00:00:00')
const start = new Date(rangeStart + 'T00:00:00')
const end = new Date(rangeEnd + 'T00:00:00')
const occurrences: string[] = []
switch (task.recurrence) {
case 'daily': {
let cur = new Date(anchor)
// Advance to range start (skip past occurrences efficiently)
if (cur < start) {
const daysBehind = Math.floor((start.getTime() - cur.getTime()) / 86400000)
const skipCycles = Math.floor(daysBehind / interval) * interval
cur.setDate(cur.getDate() + skipCycles)
}
while (cur <= end && occurrences.length < 60) {
if (cur >= start) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + interval)
}
break
}
case 'weekly': {
const targetDow = anchor.getDay()
let cur = new Date(start)
// Find first matching weekday in range
while (cur.getDay() !== targetDow && cur <= end) {
cur.setDate(cur.getDate() + 1)
}
while (cur <= end && occurrences.length < 10) {
// Check interval alignment (weeks since anchor)
const msDiff = cur.getTime() - anchor.getTime()
const weeksDiff = Math.round(msDiff / (7 * 86400000))
if (weeksDiff >= 0 && weeksDiff % interval === 0) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + 7)
}
break
}
case 'monthly': {
const targetDay = anchor.getDate()
// Check months visible in the calendar range (usually 3: prev, current, next)
for (let mOffset = -1; mOffset <= 1; mOffset++) {
let y = currentYear.value
let m = currentMonth.value + mOffset
if (m < 0) { m = 11; y-- }
if (m > 11) { m = 0; y++ }
const daysInM = new Date(y, m + 1, 0).getDate()
const day = Math.min(targetDay, daysInM)
const dateStr = formatDate(y, m, day)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const monthsDiff = (y - anchor.getFullYear()) * 12 + (m - anchor.getMonth())
if (monthsDiff >= 0 && monthsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
}
break
}
case 'yearly': {
const tgtMonth = anchor.getMonth()
const tgtDay = anchor.getDate()
const y = currentYear.value
const dateStr = formatDate(y, tgtMonth, tgtDay)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const yearsDiff = y - anchor.getFullYear()
if (yearsDiff >= 0 && yearsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
break
}
}
for (const dateStr of occurrences) {
// Skip if the task already appears on this date via its due_date
if (task.due_date === dateStr) continue
const existing = map.get(dateStr) || []
existing.push({ task, isRecurring: true })
map.set(dateStr, existing)
}
}
return map
})
function dateFromObj(d: Date): string {
return formatDate(d.getFullYear(), d.getMonth(), d.getDate())
}
// Merge regular due-date tasks and recurring occurrences for a given date
function getEntriesForDate(dateStr: string): CalendarEntry[] {
const regular = tasksByDate.value.get(dateStr) || []
const recurring = recurringByDate.value.get(dateStr) || []
return [...regular, ...recurring]
}
// Convenience: check if a date has any tasks (for cell styling)
function hasTasksOnDate(dateStr: string): boolean {
return getEntriesForDate(dateStr).length > 0
}
function hasDailyNote(dateStr: string): boolean {
@@ -211,7 +334,7 @@ onMounted(async () => {
:class="['calendar-cell', {
'other-month': !day.isCurrentMonth,
'is-today': day.isToday,
'has-tasks': getTasksForDate(day.date).length > 0,
'has-tasks': hasTasksOnDate(day.date),
}]"
>
<div class="cell-header" @click="clickDate(day.date)">
@@ -220,20 +343,21 @@ onMounted(async () => {
</div>
<div class="cell-tasks">
<div
v-for="task in getTasksForDate(day.date).slice(0, 3)"
:key="task.id"
:class="['cell-task', formatDueClass(day.date)]"
@click.stop="clickTask(task)"
:title="`${projectName(task.project_id)}: ${task.title}`"
v-for="entry in getEntriesForDate(day.date).slice(0, 3)"
:key="`${entry.task.id}-${entry.isRecurring ? 'r' : 'd'}`"
:class="['cell-task', formatDueClass(day.date), { recurring: entry.isRecurring }]"
@click.stop="clickTask(entry.task)"
:title="`${projectName(entry.task.project_id)}: ${entry.task.title}${entry.isRecurring ? ' (recurring)' : ''}`"
>
{{ task.title }}
<span v-if="entry.isRecurring" class="recurring-icon" title="Recurring">&#x21bb;</span>
{{ entry.task.title }}
</div>
<div
v-if="getTasksForDate(day.date).length > 3"
v-if="getEntriesForDate(day.date).length > 3"
class="cell-more"
@click="clickDate(day.date)"
>
+{{ getTasksForDate(day.date).length - 3 }} more
+{{ getEntriesForDate(day.date).length - 3 }} more
</div>
</div>
</div>
@@ -413,6 +537,17 @@ onMounted(async () => {
border-left-color: var(--color-warning);
}
.cell-task.recurring {
border-left-style: dashed;
opacity: 0.85;
}
.recurring-icon {
font-size: 10px;
margin-right: 2px;
opacity: 0.7;
}
.cell-more {
font-size: 10px;
color: var(--color-text-secondary);

View File

@@ -140,17 +140,24 @@ onMounted(async () => {
@click="goToTask(project.id, task)"
>
<span class="task-checkbox">&#9744;</span>
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
class="task-tag"
>{{ tag }}</span>
<span
v-if="task.due_date && formatDueDate(task.due_date)"
:class="['task-due', formatDueDate(task.due_date)?.class]"
>{{ formatDueDate(task.due_date)?.text }}</span>
<div class="card-task-info">
<div class="card-task-row">
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
class="task-tag"
>{{ tag }}</span>
<span
v-if="task.due_date && formatDueDate(task.due_date)"
:class="['task-due', formatDueDate(task.due_date)?.class]"
>{{ formatDueDate(task.due_date)?.text }}</span>
</div>
</div>
<div v-if="task.last_comment" class="card-task-comment">
{{ task.last_comment }}
</div>
</div>
</div>
<div
@@ -304,6 +311,20 @@ onMounted(async () => {
font-size: 14px;
}
.card-task-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-task-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-task-item .task-title {
flex: 1;
font-size: 13px;
@@ -313,6 +334,16 @@ onMounted(async () => {
min-width: 0;
}
.card-task-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
opacity: 0.75;
}
.task-meta {
display: flex;
gap: 4px;

View File

@@ -338,6 +338,59 @@ async function addSubtask() {
}
}
// ============ Comment Management ============
const newCommentText = ref('')
const showCommentForm = ref(false)
function openCommentForm() {
showCommentForm.value = true
newCommentText.value = ''
}
function closeCommentForm() {
showCommentForm.value = false
newCommentText.value = ''
}
async function addComment() {
if (!newCommentText.value.trim() || !tasksStore.selectedTask) return
try {
await tasksStore.addComment(projectId.value, tasksStore.selectedTask.id, newCommentText.value.trim())
closeCommentForm()
} catch {
// Error handled in store
}
}
async function deleteComment(index: number) {
if (!tasksStore.selectedTask) return
try {
await tasksStore.deleteComment(projectId.value, tasksStore.selectedTask.id, index)
} catch {
// Error handled in store
}
}
function formatCommentDate(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
} catch {
return dateStr
}
}
// ============ Due Date Management ============
async function setDueDate(date: string) {
@@ -650,6 +703,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }}
</span>
</div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div>
</template>
<button
@@ -718,6 +774,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }}
</span>
</div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div>
</template>
<button
@@ -921,6 +980,49 @@ onUnmounted(() => {
<button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button>
</div>
<!-- Comments Section -->
<div class="comments-panel">
<div class="comments-header">
<span class="comments-label">Comments ({{ tasksStore.selectedTask.comments?.length || 0 }})</span>
<button v-if="!showCommentForm" class="tag-add-btn" @click="openCommentForm">+ Comment</button>
</div>
<!-- Add comment form -->
<div v-if="showCommentForm" class="comment-form">
<textarea
v-model="newCommentText"
class="comment-input"
placeholder="Add a comment or status update..."
rows="2"
@keydown.ctrl.enter="addComment"
@keyup.escape="closeCommentForm"
autofocus
></textarea>
<div class="comment-form-actions">
<button class="primary small" @click="addComment" :disabled="!newCommentText.trim()">Add Comment</button>
<button class="small" @click="closeCommentForm">Cancel</button>
<span class="comment-hint">Ctrl+Enter to submit</span>
</div>
</div>
<!-- Comment list (newest first) -->
<div v-if="tasksStore.selectedTask.comments?.length > 0" class="comment-list">
<div
v-for="(comment, index) in [...tasksStore.selectedTask.comments].reverse()"
:key="index"
class="comment-item"
>
<div class="comment-meta">
<span class="comment-date">{{ formatCommentDate(comment.date) }}</span>
<button
class="comment-delete"
@click="deleteComment(tasksStore.selectedTask.comments.length - 1 - index)"
title="Delete comment"
>&times;</button>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
</div>
<div class="editor-content">
<div class="editor-pane">
<MilkdownEditor
@@ -1507,6 +1609,22 @@ button.small {
color: white;
}
/* Last comment preview in task list */
.task-last-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0.75;
font-style: italic;
}
.task-item.selected .task-last-comment {
color: rgba(255, 255, 255, 0.7);
}
/* Tag Editor Bar (detail panel) */
.tag-editor-bar {
display: flex;
@@ -1601,6 +1719,118 @@ button.small {
background: var(--color-bg-hover);
}
/* Comments Panel */
.comments-panel {
border-bottom: 1px solid var(--color-border);
padding: 8px 16px;
flex-shrink: 0;
max-height: 280px;
overflow-y: auto;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.comments-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.comment-form {
margin-bottom: 8px;
}
.comment-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
box-sizing: border-box;
}
.comment-input:focus {
border-color: var(--color-primary);
}
.comment-form-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.comment-hint {
font-size: 11px;
color: var(--color-text-secondary);
margin-left: auto;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-item {
padding: 8px 10px;
border-radius: 6px;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.comment-date {
font-size: 11px;
color: var(--color-text-secondary);
}
.comment-delete {
padding: 0 4px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.comment-item:hover .comment-delete {
opacity: 1;
}
.comment-delete:hover {
color: var(--color-danger);
}
.comment-text {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Error Banner */
.error-banner {
position: absolute;