Compare commits
10 Commits
0ea3fcfa6d
...
864e5b712c
| Author | SHA1 | Date | |
|---|---|---|---|
| 864e5b712c | |||
| 4b2a5a2707 | |||
| 5db92d5127 | |||
| 74cd0f0018 | |||
| 71d33e355c | |||
| c71795c13f | |||
| f4d983a02b | |||
| ea688c0ab0 | |||
| 400e74be51 | |||
| 8461bf788c |
@@ -8,3 +8,4 @@ node_modules/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
dist/
|
dist/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ public class TasksController(ITaskRepository taskRepo, ILogger<TasksController>
|
|||||||
if (request.Description is not null) task.Description = request.Description;
|
if (request.Description is not null) task.Description = request.Description;
|
||||||
if (request.Category is not null) task.Category = request.Category;
|
if (request.Category is not null) task.Category = request.Category;
|
||||||
if (request.EstimatedMinutes.HasValue) task.EstimatedMinutes = request.EstimatedMinutes;
|
if (request.EstimatedMinutes.HasValue) task.EstimatedMinutes = request.EstimatedMinutes;
|
||||||
|
if (request.ParentTaskId.HasValue)
|
||||||
|
{
|
||||||
|
var parent = await taskRepo.GetByIdAsync(request.ParentTaskId.Value);
|
||||||
|
if (parent is null)
|
||||||
|
return BadRequest(ApiResponse.Fail("Parent task not found"));
|
||||||
|
task.ParentTaskId = request.ParentTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task);
|
await taskRepo.UpdateAsync(task);
|
||||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ public class UpdateTaskRequest
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
public int? EstimatedMinutes { get; set; }
|
public int? EstimatedMinutes { get; set; }
|
||||||
|
public int? ParentTaskId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ public sealed class TaskTools
|
|||||||
HttpClient client,
|
HttpClient client,
|
||||||
[Description("Title of the task")] string title,
|
[Description("Title of the task")] string title,
|
||||||
[Description("Optional description")] string? description = null,
|
[Description("Optional description")] string? description = null,
|
||||||
[Description("Optional category (e.g. Engineering, Email, LaserCutting)")] string? category = null)
|
[Description("Optional category (e.g. Engineering, Email, LaserCutting)")] string? category = null,
|
||||||
|
[Description("Optional parent task ID to create this as a subtask")] int? parentTaskId = null)
|
||||||
{
|
{
|
||||||
var payload = new { title, description, category };
|
var payload = new { title, description, category, parentTaskId };
|
||||||
var response = await client.PostAsJsonAsync("/api/tasks", payload, JsonOptions);
|
var response = await client.PostAsJsonAsync("/api/tasks", payload, JsonOptions);
|
||||||
return await response.Content.ReadAsStringAsync();
|
return await response.Content.ReadAsStringAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface NotesListProps {
|
|||||||
notes: TaskNote[]
|
notes: TaskNote[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTE_TYPE_CONFIG: Record<string, { label: string; color: string }> = {
|
const NOTE_TYPE_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
|
||||||
[NoteType.PauseNote]: { label: 'Pause', color: '#f59e0b' },
|
[NoteType.PauseNote]: { label: 'Pause', bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
||||||
[NoteType.ResumeNote]: { label: 'Resume', color: '#6366f1' },
|
[NoteType.ResumeNote]: { label: 'Resume', bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
||||||
[NoteType.General]: { label: 'General', color: '#64748b' },
|
[NoteType.General]: { label: 'General', bg: 'bg-white/5', text: 'text-[var(--color-text-secondary)]' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
@@ -68,12 +68,12 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
|
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
Notes
|
Notes
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInput(true)}
|
onClick={() => setShowInput(true)}
|
||||||
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
|
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -86,25 +86,21 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
|||||||
<div key={note.id} className="text-sm">
|
<div key={note.id} className="text-sm">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded-full"
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${typeConfig.bg} ${typeConfig.text}`}
|
||||||
style={{
|
|
||||||
backgroundColor: typeConfig.color + '20',
|
|
||||||
color: typeConfig.color,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{typeConfig.label}
|
{typeConfig.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-[#64748b]">
|
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||||
{formatRelativeTime(note.createdAt)}
|
{formatRelativeTime(note.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#c4c9d4] leading-relaxed">{note.content}</p>
|
<p className="text-[var(--color-text-primary)] leading-relaxed">{note.content}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{sortedNotes.length === 0 && !showInput && (
|
{sortedNotes.length === 0 && !showInput && (
|
||||||
<p className="text-sm text-[#64748b] italic">No notes yet</p>
|
<p className="text-sm text-[var(--color-text-secondary)] italic">No notes yet</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
@@ -121,7 +117,7 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Add a note..."
|
placeholder="Add a note..."
|
||||||
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
|
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
|
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
Subtasks
|
Subtasks
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInput(true)}
|
onClick={() => setShowInput(true)}
|
||||||
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
|
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -67,13 +67,13 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
onClick={() => handleToggle(subtask)}
|
onClick={() => handleToggle(subtask)}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<CheckSquare size={16} className="text-emerald-400 flex-shrink-0" />
|
<CheckSquare size={16} className="text-[var(--color-status-completed)] flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<Square size={16} className="text-[#64748b] group-hover:text-white flex-shrink-0" />
|
<Square size={16} className="text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`text-sm ${
|
||||||
isCompleted ? 'line-through text-[#64748b]' : 'text-white'
|
isCompleted ? 'line-through text-[var(--color-text-secondary)]' : 'text-[var(--color-text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{subtask.title}
|
{subtask.title}
|
||||||
@@ -84,7 +84,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<div className="flex items-center gap-2 py-1.5 px-1">
|
<div className="flex items-center gap-2 py-1.5 px-1">
|
||||||
<Square size={16} className="text-[#64748b] flex-shrink-0" />
|
<Square size={16} className="text-[var(--color-text-secondary)] flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -98,7 +98,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="New subtask..."
|
placeholder="New subtask..."
|
||||||
className="flex-1 bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
|
className="flex-1 bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
if (eventsLoading || mappingsLoading) {
|
if (eventsLoading || mappingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
|
||||||
Loading activity...
|
Loading activity...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
if (sortedEvents.length === 0) {
|
if (sortedEvents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
|
||||||
No activity events for this time range.
|
No activity events for this time range.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -90,30 +90,36 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="divide-y divide-white/5">
|
<div className="relative">
|
||||||
{visibleEvents.map((evt) => {
|
{visibleEvents.map((evt, idx) => {
|
||||||
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
||||||
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
||||||
const detail = evt.url || evt.windowTitle || ''
|
const detail = evt.url || evt.windowTitle || ''
|
||||||
|
const isLast = idx === visibleEvents.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={evt.id} className="flex items-start gap-3 py-3">
|
<div key={evt.id} className="flex items-start gap-3 relative">
|
||||||
{/* Category dot */}
|
{/* Timeline connector + dot */}
|
||||||
<span
|
<div className="flex flex-col items-center shrink-0">
|
||||||
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
|
<span
|
||||||
style={{ backgroundColor: color }}
|
className="w-2 h-2 rounded-full mt-1.5 shrink-0 relative z-10"
|
||||||
/>
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
{!isLast && (
|
||||||
|
<div className="w-px flex-1 bg-[var(--color-border)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-[#94a3b8] shrink-0">
|
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
|
||||||
{formatTimestamp(evt.timestamp)}
|
{formatTimestamp(evt.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-white font-medium truncate">{evt.appName}</span>
|
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
|
||||||
</div>
|
</div>
|
||||||
{detail && (
|
{detail && (
|
||||||
<p className="text-xs text-[#64748b] truncate mt-0.5">{detail}</p>
|
<p className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">{detail}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +130,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
{hasMore && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||||
className="mt-3 w-full py-2 text-sm text-[#94a3b8] hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
className="mt-3 w-full py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Load more ({sortedEvents.length - visibleCount} remaining)
|
Load more ({sortedEvents.length - visibleCount} remaining)
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
Loading category breakdown...
|
Loading category breakdown...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -52,7 +52,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
No category data available.
|
No category data available.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -88,14 +88,14 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-white text-sm font-medium">{d.name}</div>
|
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
|
||||||
<div className="text-[#94a3b8] text-xs mt-0.5">
|
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
|
||||||
{d.count} events ({d.percentage}%)
|
{d.count} events ({d.percentage}%)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,13 +119,13 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
{/* Name + bar + stats */}
|
{/* Name + bar + stats */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-sm text-white font-medium truncate">{cat.name}</span>
|
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
|
||||||
<span className="text-xs text-[#94a3b8] ml-2 shrink-0">
|
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
|
||||||
{cat.count} ({cat.percentage}%)
|
{cat.count} ({cat.percentage}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
|
<div className="h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-300"
|
className="h-full rounded-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
|
|
||||||
if (eventsLoading || mappingsLoading) {
|
if (eventsLoading || mappingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
Loading timeline...
|
Loading timeline...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -144,7 +144,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
|
|
||||||
if (buckets.length === 0) {
|
if (buckets.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
No activity data for this time range.
|
No activity data for this time range.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -156,12 +156,12 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||||
axisLine={{ stroke: '#1e293b' }}
|
axisLine={{ stroke: 'var(--color-border)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
@@ -169,8 +169,8 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
@@ -181,14 +181,14 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[#94a3b8] text-xs">{d.timeRange}</div>
|
<div className="text-[var(--color-text-secondary)] text-xs">{d.timeRange}</div>
|
||||||
<div className="text-white text-sm font-medium mt-0.5">{d.appName}</div>
|
<div className="text-[var(--color-text-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
||||||
{d.count} events
|
{d.count} events
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ export default function Analytics() {
|
|||||||
<div className="max-w-6xl mx-auto space-y-8">
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
{/* Header + Filters */}
|
{/* Header + Filters */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
<h1 className="text-xl font-semibold text-white">Analytics</h1>
|
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Analytics</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Time range dropdown */}
|
{/* Time range dropdown */}
|
||||||
<select
|
<select
|
||||||
value={minutes}
|
value={minutes}
|
||||||
onChange={(e) => setMinutes(Number(e.target.value))}
|
onChange={(e) => setMinutes(Number(e.target.value))}
|
||||||
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer"
|
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
{TIME_RANGES.map((r) => (
|
{TIME_RANGES.map((r) => (
|
||||||
<option key={r.minutes} value={r.minutes}>
|
<option key={r.minutes} value={r.minutes}>
|
||||||
@@ -39,7 +39,7 @@ export default function Analytics() {
|
|||||||
<select
|
<select
|
||||||
value={taskId ?? ''}
|
value={taskId ?? ''}
|
||||||
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
|
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer max-w-[200px]"
|
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer max-w-[200px]"
|
||||||
>
|
>
|
||||||
<option value="">All Tasks</option>
|
<option value="">All Tasks</option>
|
||||||
{tasks?.map((t) => (
|
{tasks?.map((t) => (
|
||||||
@@ -51,32 +51,69 @@ export default function Analytics() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Open Tasks</span>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||||
|
{tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Active Time</span>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||||
|
{(() => {
|
||||||
|
const totalMins = tasks?.reduce((acc, t) => {
|
||||||
|
if (!t.startedAt) return acc
|
||||||
|
const start = new Date(t.startedAt).getTime()
|
||||||
|
const end = t.completedAt ? new Date(t.completedAt).getTime() : (t.status === 'Active' ? Date.now() : start)
|
||||||
|
return acc + (end - start) / 60000
|
||||||
|
}, 0) ?? 0
|
||||||
|
const hours = Math.floor(totalMins / 60)
|
||||||
|
const mins = Math.floor(totalMins % 60)
|
||||||
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Top Category</span>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||||
|
{(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
tasks?.forEach(t => { counts[t.category ?? 'Unknown'] = (counts[t.category ?? 'Unknown'] || 0) + 1 })
|
||||||
|
const top = Object.entries(counts).sort(([,a], [,b]) => b - a)[0]
|
||||||
|
return top ? top[0] : '\u2014'
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||||
Activity Timeline
|
Activity Timeline
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||||
<Timeline minutes={minutes} taskId={taskId} />
|
<Timeline minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||||
Category Breakdown
|
Category Breakdown
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||||
<CategoryBreakdown minutes={minutes} taskId={taskId} />
|
<CategoryBreakdown minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity Feed */}
|
{/* Activity Feed */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||||
<ActivityFeed minutes={minutes} taskId={taskId} />
|
<ActivityFeed minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Pencil, Trash2, Check, X, Plus } from 'lucide-react'
|
import { Pencil, Trash2, Check, X, Plus, Link } from 'lucide-react'
|
||||||
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
|
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
|
||||||
import { CATEGORY_COLORS } from '../lib/constants'
|
import { CATEGORY_COLORS } from '../lib/constants'
|
||||||
import type { AppMapping } from '../types'
|
import type { AppMapping } from '../types'
|
||||||
@@ -102,13 +102,13 @@ export default function Mappings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors w-full'
|
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors w-full'
|
||||||
const selectClass =
|
const selectClass =
|
||||||
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer w-full'
|
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer w-full'
|
||||||
|
|
||||||
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
|
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
|
||||||
return (
|
return (
|
||||||
<tr className="bg-[#1a1d27]">
|
<tr className="bg-white/[0.04]">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -162,7 +162,7 @@ export default function Mappings() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="p-1.5 rounded text-[#94a3b8] hover:bg-white/5 transition-colors"
|
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:bg-white/5 transition-colors"
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
@@ -177,7 +177,7 @@ export default function Mappings() {
|
|||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold text-white">App Mappings</h1>
|
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">App Mappings</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddingNew(true)
|
setAddingNew(true)
|
||||||
@@ -185,7 +185,7 @@ export default function Mappings() {
|
|||||||
setNewForm(emptyForm)
|
setNewForm(emptyForm)
|
||||||
}}
|
}}
|
||||||
disabled={addingNew}
|
disabled={addingNew}
|
||||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
className="flex items-center gap-1.5 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add Rule
|
Add Rule
|
||||||
@@ -194,33 +194,34 @@ export default function Mappings() {
|
|||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-[#94a3b8] text-sm py-12 text-center">Loading mappings...</div>
|
<div className="text-[var(--color-text-secondary)] text-sm py-12 text-center">Loading mappings...</div>
|
||||||
) : !mappings?.length && !addingNew ? (
|
) : !mappings?.length && !addingNew ? (
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-12 text-center">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
|
||||||
<p className="text-[#94a3b8] text-sm mb-3">No mappings configured</p>
|
<Link size={40} className="text-[var(--color-text-tertiary)] mx-auto mb-3" />
|
||||||
|
<p className="text-[var(--color-text-secondary)] text-sm mb-3">No mappings configured</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddingNew(true)
|
setAddingNew(true)
|
||||||
setNewForm(emptyForm)
|
setNewForm(emptyForm)
|
||||||
}}
|
}}
|
||||||
className="text-indigo-400 hover:text-indigo-300 text-sm font-medium transition-colors"
|
className="text-[var(--color-accent)] hover:brightness-110 text-sm font-medium transition-all"
|
||||||
>
|
>
|
||||||
+ Add your first mapping rule
|
+ Add your first mapping rule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 overflow-hidden">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#161922] border-b border-white/5">
|
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Pattern</th>
|
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Pattern</th>
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Match Type</th>
|
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Match Type</th>
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Category</th>
|
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Category</th>
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Friendly Name</th>
|
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium w-24">Actions</th>
|
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium w-24">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody>
|
||||||
{/* Add-new row */}
|
{/* Add-new row */}
|
||||||
{addingNew &&
|
{addingNew &&
|
||||||
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
|
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
|
||||||
@@ -232,9 +233,9 @@ export default function Mappings() {
|
|||||||
) : (
|
) : (
|
||||||
<tr
|
<tr
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="bg-[#1a1d27] hover:bg-[#1e2230] transition-colors"
|
className="border-b border-[var(--color-border)] hover:bg-white/[0.03] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-white font-mono text-xs">{m.pattern}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)] font-mono text-xs">{m.pattern}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
|
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
|
||||||
@@ -247,7 +248,7 @@ export default function Mappings() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="inline-flex items-center gap-1.5 text-white text-xs">
|
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] text-xs">
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
|
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
|
||||||
@@ -255,21 +256,21 @@ export default function Mappings() {
|
|||||||
{m.category}
|
{m.category}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[#94a3b8]">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{m.friendlyName ?? '\u2014'}
|
{m.friendlyName ?? '\u2014'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditStart(m)}
|
onClick={() => handleEditStart(m)}
|
||||||
className="p-1.5 rounded text-[#94a3b8] hover:text-white hover:bg-white/5 transition-colors"
|
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/5 transition-colors"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(m.id)}
|
onClick={() => handleDelete(m.id)}
|
||||||
className="p-1.5 rounded text-[#94a3b8] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
|
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -13,4 +13,23 @@ internal static partial class NativeMethods
|
|||||||
|
|
||||||
[LibraryImport("user32.dll", SetLastError = true)]
|
[LibraryImport("user32.dll", SetLastError = true)]
|
||||||
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct LASTINPUTINFO
|
||||||
|
{
|
||||||
|
public uint cbSize;
|
||||||
|
public uint dwTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
||||||
|
|
||||||
|
internal static TimeSpan GetIdleTime()
|
||||||
|
{
|
||||||
|
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>() };
|
||||||
|
if (!GetLastInputInfo(ref info))
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
// Use 32-bit TickCount so both values wrap at the same boundary as dwTime (uint)
|
||||||
|
return TimeSpan.FromMilliseconds(unchecked((uint)Environment.TickCount - info.dwTime));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ public class WindowWatcherOptions
|
|||||||
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
|
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
|
||||||
public int PollIntervalMs { get; set; } = 2000;
|
public int PollIntervalMs { get; set; } = 2000;
|
||||||
public int DebounceMs { get; set; } = 3000;
|
public int DebounceMs { get; set; } = 3000;
|
||||||
|
public int IdleTimeoutMs { get; set; } = 300_000;
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-2
@@ -15,12 +15,15 @@ public class Worker(
|
|||||||
private string _lastAppName = string.Empty;
|
private string _lastAppName = string.Empty;
|
||||||
private string _lastWindowTitle = string.Empty;
|
private string _lastWindowTitle = string.Empty;
|
||||||
private DateTime _lastChangeTime = DateTime.MinValue;
|
private DateTime _lastChangeTime = DateTime.MinValue;
|
||||||
|
private bool _isIdle;
|
||||||
|
private int? _pausedTaskId;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var config = options.Value;
|
var config = options.Value;
|
||||||
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
|
logger.LogInformation(
|
||||||
config.PollIntervalMs, config.DebounceMs);
|
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
|
||||||
|
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -67,6 +70,26 @@ public class Worker(
|
|||||||
_lastWindowTitle = windowTitle;
|
_lastWindowTitle = windowTitle;
|
||||||
_lastChangeTime = now;
|
_lastChangeTime = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idle detection
|
||||||
|
var idleTime = NativeMethods.GetIdleTime();
|
||||||
|
if (!_isIdle && _pausedTaskId is not null && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
|
||||||
|
{
|
||||||
|
// Retry resume from a previous failed attempt
|
||||||
|
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
|
||||||
|
}
|
||||||
|
else if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs)
|
||||||
|
{
|
||||||
|
_isIdle = true;
|
||||||
|
logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime);
|
||||||
|
await PauseActiveTaskAsync(ct: stoppingToken);
|
||||||
|
}
|
||||||
|
else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
|
||||||
|
{
|
||||||
|
_isIdle = false;
|
||||||
|
logger.LogInformation("User returned from idle");
|
||||||
|
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -108,4 +131,85 @@ public class Worker(
|
|||||||
logger.LogWarning(ex, "Failed to report context event to API");
|
logger.LogWarning(ex, "Failed to report context event to API");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PauseActiveTaskAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||||
|
|
||||||
|
// Get the active task
|
||||||
|
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
|
||||||
|
"/api/tasks/active", ct);
|
||||||
|
|
||||||
|
if (response?.Data is null)
|
||||||
|
{
|
||||||
|
logger.LogDebug("No active task to pause");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pausedTaskId = response.Data.Id;
|
||||||
|
|
||||||
|
// Pause it
|
||||||
|
var pauseResponse = await client.PutAsJsonAsync(
|
||||||
|
$"/api/tasks/{_pausedTaskId}/pause",
|
||||||
|
new { note = "Auto-paused: idle timeout" }, ct);
|
||||||
|
|
||||||
|
if (pauseResponse.IsSuccessStatusCode)
|
||||||
|
logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId);
|
||||||
|
else
|
||||||
|
logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to pause active task on idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResumeIdlePausedTaskAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_pausedTaskId is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var taskId = _pausedTaskId.Value;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||||
|
|
||||||
|
// Check the task is still paused (user may have manually switched tasks)
|
||||||
|
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
|
||||||
|
$"/api/tasks/{taskId}", ct);
|
||||||
|
|
||||||
|
if (response?.Data is null || response.Data.Status != "Paused")
|
||||||
|
{
|
||||||
|
logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId);
|
||||||
|
_pausedTaskId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume it
|
||||||
|
var resumeResponse = await client.PutAsJsonAsync(
|
||||||
|
$"/api/tasks/{taskId}/resume",
|
||||||
|
new { note = "Auto-resumed: user returned" }, ct);
|
||||||
|
|
||||||
|
if (resumeResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Auto-resumed task {TaskId}", taskId);
|
||||||
|
_pausedTaskId = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to resume task {TaskId}: {Status}, will retry", taskId, resumeResponse.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to resume task {TaskId} after idle, will retry", taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal record ActiveTaskDto(int Id, string Status);
|
||||||
|
|
||||||
|
internal record ApiResponse<T>(bool Success, T? Data, string? Error);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"WindowWatcher": {
|
"WindowWatcher": {
|
||||||
"ApiBaseUrl": "http://localhost:5200",
|
"ApiBaseUrl": "http://localhost:5200",
|
||||||
"PollIntervalMs": 2000,
|
"PollIntervalMs": 2000,
|
||||||
"DebounceMs": 3000
|
"DebounceMs": 3000,
|
||||||
|
"IdleTimeoutMs": 300000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user