Merge commit '559815f'

This commit is contained in:
xarmian
2026-03-28 14:17:07 +00:00
3 changed files with 259 additions and 2 deletions
@@ -0,0 +1,251 @@
<script lang="ts">
import type { Item } from '$lib/types';
import { parseFields } from '$lib/types';
interface Props {
tasks: Item[];
startDate: string;
endDate?: string;
}
let { tasks, startDate, endDate }: Props = $props();
// Chart dimensions
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
const width = 600;
const height = 200;
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
let chartData = $derived.by(() => {
const total = tasks.length;
if (total === 0) return null;
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(23, 59, 59, 999);
const end = endDate ? new Date(endDate) : now;
end.setHours(23, 59, 59, 999);
// Use the later of end date or today for display range
const displayEnd = end > now ? end : now;
const totalMs = displayEnd.getTime() - start.getTime();
if (totalMs <= 0) return null;
// Build completion events: when each done task was completed
const completions: { date: Date; count: number }[] = [];
for (const task of tasks) {
const f = parseFields(task);
if (f.status === 'done') {
const completedAt = new Date(task.updated_at);
completions.push({ date: completedAt, count: 1 });
}
}
completions.sort((a, b) => a.date.getTime() - b.date.getTime());
// Build actual line points (step chart)
const actualPoints: { x: number; y: number }[] = [];
let remaining = total;
// Start point
actualPoints.push({
x: 0,
y: (remaining / total) * chartH
});
for (const c of completions) {
const xPct = (c.date.getTime() - start.getTime()) / totalMs;
const x = Math.max(0, Math.min(chartW, xPct * chartW));
// Horizontal step to this date at current remaining level
actualPoints.push({ x, y: (remaining / total) * chartH });
remaining -= c.count;
// Vertical drop to new remaining level
actualPoints.push({ x, y: (remaining / total) * chartH });
}
// Extend to today (or end, whichever is shown)
const nowX = Math.max(0, Math.min(chartW, ((now.getTime() - start.getTime()) / totalMs) * chartW));
actualPoints.push({ x: nowX, y: (remaining / total) * chartH });
// Ideal line: straight from (start, total) to (end, 0)
const idealEndX = Math.max(0, Math.min(chartW, ((end.getTime() - start.getTime()) / totalMs) * chartW));
const idealLine = {
x1: 0,
y1: 0,
x2: idealEndX,
y2: chartH
};
// Today marker
const todayX = ((now.getTime() - start.getTime()) / totalMs) * chartW;
// Y axis ticks
const yTicks: { value: number; y: number }[] = [];
const step = total <= 5 ? 1 : total <= 20 ? Math.ceil(total / 5) : Math.ceil(total / 4);
for (let v = 0; v <= total; v += step) {
yTicks.push({ value: v, y: ((total - v) / total) * chartH });
}
if (yTicks[yTicks.length - 1]?.value !== total) {
yTicks.push({ value: total, y: 0 });
}
// X axis labels
const formatDate = (d: Date) => {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
// Build the actual line SVG path
const actualPath = actualPoints
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
.join(' ');
// Build filled area path (close to bottom)
const areaPath =
actualPath +
` L${actualPoints[actualPoints.length - 1].x},${chartH} L0,${chartH} Z`;
return {
total,
remaining,
actualPath,
areaPath,
idealLine,
todayX,
yTicks,
startLabel: formatDate(start),
endLabel: formatDate(end),
showTodayMarker: todayX > 0 && todayX < chartW
};
});
</script>
{#if chartData}
<div class="phase-chart">
<svg viewBox="0 0 {width} {height}" preserveAspectRatio="xMidYMid meet">
<g transform="translate({padding.left},{padding.top})">
<!-- Y axis gridlines -->
{#each chartData.yTicks as tick (tick.value)}
<line
x1={0}
y1={tick.y}
x2={chartW}
y2={tick.y}
stroke="var(--border)"
stroke-width="0.5"
/>
<text
x={-8}
y={tick.y}
text-anchor="end"
dominant-baseline="middle"
fill="var(--text-muted)"
font-size="11"
>
{tick.value}
</text>
{/each}
<!-- Ideal line (dashed, gray) -->
<line
x1={chartData.idealLine.x1}
y1={chartData.idealLine.y1}
x2={chartData.idealLine.x2}
y2={chartData.idealLine.y2}
stroke="var(--text-muted)"
stroke-width="1.5"
stroke-dasharray="6 4"
opacity="0.6"
/>
<!-- Actual area fill -->
<path
d={chartData.areaPath}
fill="var(--accent-green)"
opacity="0.1"
/>
<!-- Actual line -->
<path
d={chartData.actualPath}
fill="none"
stroke="var(--accent-green)"
stroke-width="2"
stroke-linejoin="round"
/>
<!-- Today marker -->
{#if chartData.showTodayMarker}
<line
x1={chartData.todayX}
y1={0}
x2={chartData.todayX}
y2={chartH}
stroke="var(--accent-blue)"
stroke-width="1"
stroke-dasharray="4 3"
opacity="0.7"
/>
<text
x={chartData.todayX}
y={-6}
text-anchor="middle"
fill="var(--accent-blue)"
font-size="10"
opacity="0.8"
>
today
</text>
{/if}
<!-- X axis: baseline -->
<line
x1={0}
y1={chartH}
x2={chartW}
y2={chartH}
stroke="var(--border)"
stroke-width="1"
/>
<!-- X axis labels -->
<text
x={0}
y={chartH + 18}
text-anchor="start"
fill="var(--text-muted)"
font-size="11"
>
{chartData.startLabel}
</text>
<text
x={chartW}
y={chartH + 18}
text-anchor="end"
fill="var(--text-muted)"
font-size="11"
>
{chartData.endLabel}
</text>
</g>
</svg>
</div>
{/if}
<style>
.phase-chart {
width: 100%;
margin: var(--space-3) 0;
}
.phase-chart svg {
width: 100%;
height: 200px;
display: block;
}
</style>
@@ -5,14 +5,16 @@
import { parseFields, formatItemRef } from '$lib/types';
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import type { DndEvent } from 'svelte-dnd-action';
import PhaseChart from './PhaseChart.svelte';
interface Props {
wsSlug: string;
itemSlug: string;
itemId: string;
phaseFields?: Record<string, any>;
}
let { wsSlug, itemSlug, itemId }: Props = $props();
let { wsSlug, itemSlug, itemId, phaseFields }: Props = $props();
let tasks = $state<Item[]>([]);
let loading = $state(true);
@@ -123,6 +125,10 @@
<div class="progress-fill" style:width="{percentage}%"></div>
</div>
{#if !loading && tasks.length > 0 && phaseFields?.start_date}
<PhaseChart {tasks} startDate={phaseFields.start_date} endDate={phaseFields.end_date} />
{/if}
{#if loading}
<div class="loading">
<span class="spinner"></span>
@@ -454,7 +454,7 @@
<!-- Phase Tasks (shown only for phases collection) -->
{#if collSlug === 'phases' && item}
<PhaseTasks {wsSlug} {itemSlug} itemId={item.id} />
<PhaseTasks {wsSlug} {itemSlug} itemId={item.id} phaseFields={fields} />
{/if}
<!-- Comments -->