{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "s-curve",
	"title": "S-Curve",
	"type": "registry:ui",
	"description": "A planned-versus-actual cumulative progress chart with per-task history and schedule variance in days.",
	"dependencies": [
		"@lucide/svelte@^1.28.0",
		"layerchart@^2.2.0"
	],
	"registryDependencies": [
		"./status-badge.json",
		"card",
		"chart",
		"empty",
		"select",
		"skeleton"
	],
	"files": [
		{
			"content": "export {\n\tactualPercentAt,\n\tbuildSCurve,\n\tearnedPercent,\n\tplannedPercentAt,\n\tscheduleVarianceDays,\n\tS_CURVE_SCALES\n} from './s-curve-lib.js';\nexport type {\n\tSCurvePoint,\n\tSCurveProgressPoint,\n\tSCurveProps,\n\tSCurveScale,\n\tSCurveSeries,\n\tSCurveTask\n} from './s-curve-types.js';\nexport { default as SCurve } from './s-curve.svelte';\n",
			"type": "registry:file",
			"target": "s-curve/index.ts"
		},
		{
			"content": "import type {\n\tSCurvePoint,\n\tSCurveScale,\n\tSCurveSeries,\n\tSCurveTask\n} from './s-curve-types.js';\n\n/** A curve wider than this is bucketed no finer than the cap, so points stay bounded. */\nconst MAX_POINTS = 240;\n\nexport const S_CURVE_SCALES: readonly SCurveScale[] = ['week', 'month', 'quarter'];\n\nexport function startOfDay(date: Date): Date {\n\treturn new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\n/** Weeks start on Monday, matching the Gantt timeline. */\nexport function startOfBucket(scale: SCurveScale, date: Date): Date {\n\tif (scale === 'week') {\n\t\tconst result = startOfDay(date);\n\t\tresult.setDate(result.getDate() - ((result.getDay() + 6) % 7));\n\t\treturn result;\n\t}\n\tif (scale === 'month') return new Date(date.getFullYear(), date.getMonth(), 1);\n\treturn new Date(date.getFullYear(), Math.floor(date.getMonth() / 3) * 3, 1);\n}\n\nexport function nextBucket(scale: SCurveScale, date: Date): Date {\n\tif (scale === 'week') {\n\t\tconst result = new Date(date);\n\t\tresult.setDate(result.getDate() + 7);\n\t\treturn result;\n\t}\n\tconst months = scale === 'month' ? 1 : 3;\n\treturn new Date(date.getFullYear(), date.getMonth() + months, 1);\n}\n\n/**\n * The finest interval that still keeps the curve readable for the schedule's\n * span. A six-week project bucketed by month has four points and hides every\n * step its readings made.\n */\nexport function defaultCurveScale(tasks: readonly SCurveTask[]): SCurveScale {\n\tconst scheduled = tasks.filter(isSchedulable);\n\tif (scheduled.length === 0) return 'month';\n\tconst start = Math.min(...scheduled.map((task) => task.start.getTime()));\n\tconst end = Math.max(...scheduled.map((task) => task.end.getTime()));\n\tconst days = (end - start) / (24 * 60 * 60 * 1000);\n\tif (days <= 120) return 'week';\n\tif (days <= 730) return 'month';\n\treturn 'quarter';\n}\n\nexport function taskWeight(task: SCurveTask): number {\n\tconst weight = task.weight ?? 1;\n\treturn Number.isFinite(weight) && weight > 0 ? weight : 0;\n}\n\nfunction isSchedulable(task: SCurveTask): boolean {\n\treturn (\n\t\t!Number.isNaN(task.start.getTime()) && !Number.isNaN(task.end.getTime()) && taskWeight(task) > 0\n\t);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.min(Math.max(value, min), max);\n}\n\n/**\n * Fraction of a task planned to be finished by `date`, spread evenly across its\n * days. A zero- or negative-length span is a milestone: nothing until its end.\n */\nexport function taskPlannedFraction(task: SCurveTask, date: Date): number {\n\tconst span = task.end.getTime() - task.start.getTime();\n\tif (span <= 0) return date.getTime() >= task.end.getTime() ? 1 : 0;\n\treturn clamp((date.getTime() - task.start.getTime()) / span, 0, 1);\n}\n\nexport function plannedPercentAt(tasks: readonly SCurveTask[], date: Date): number {\n\tlet total = 0;\n\tlet earned = 0;\n\tfor (const task of tasks) {\n\t\tif (!isSchedulable(task)) continue;\n\t\tconst weight = taskWeight(task);\n\t\ttotal += weight;\n\t\tearned += weight * taskPlannedFraction(task, date);\n\t}\n\treturn total === 0 ? 0 : (earned / total) * 100;\n}\n\n/** Reported cumulative completion across the schedule, weighted like the plan. */\nexport function earnedPercent(tasks: readonly SCurveTask[]): number {\n\tlet total = 0;\n\tlet earned = 0;\n\tfor (const task of tasks) {\n\t\tif (!isSchedulable(task)) continue;\n\t\tconst weight = taskWeight(task);\n\t\ttotal += weight;\n\t\tearned += weight * (clamp(task.percentComplete ?? 0, 0, 100) / 100);\n\t}\n\treturn total === 0 ? 0 : (earned / total) * 100;\n}\n\n/**\n * A task's recorded progress at `date`: its latest reading on or before it, or\n * zero before its first. Readings are absolute observations, so the value holds\n * until the next one rather than drifting between them.\n */\nexport function taskActualFraction(task: SCurveTask, date: Date): number {\n\tlet percent = 0;\n\tlet found = Number.NEGATIVE_INFINITY;\n\tfor (const reading of task.readings ?? []) {\n\t\tconst time = reading.date.getTime();\n\t\tif (Number.isNaN(time) || !Number.isFinite(reading.percent)) continue;\n\t\t// `>=` keeps the later of two same-day readings, as supplied.\n\t\tif (time <= date.getTime() && time >= found) {\n\t\t\tfound = time;\n\t\t\tpercent = reading.percent;\n\t\t}\n\t}\n\treturn clamp(percent, 0, 100) / 100;\n}\n\n/** Recorded cumulative completion at `date`, weighted like the plan. */\nexport function actualPercentAt(tasks: readonly SCurveTask[], date: Date): number {\n\tlet total = 0;\n\tlet earned = 0;\n\tfor (const task of tasks) {\n\t\tif (!isSchedulable(task)) continue;\n\t\tconst weight = taskWeight(task);\n\t\ttotal += weight;\n\t\tearned += weight * taskActualFraction(task, date);\n\t}\n\treturn total === 0 ? 0 : (earned / total) * 100;\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction addDays(date: Date, days: number): Date {\n\treturn new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);\n}\n\nfunction daysBetween(from: Date, to: Date): number {\n\treturn Math.round((startOfDay(to).getTime() - startOfDay(from).getTime()) / DAY_MS);\n}\n\n/**\n * First day in `[from, to]` on which the plan reaches `percent`. The planned\n * curve never falls, so a binary search over days finds it.\n */\nfunction firstDayPlannedAt(\n\ttasks: readonly SCurveTask[],\n\tpercent: number,\n\tfrom: Date,\n\tto: Date\n): Date {\n\tconst tolerance = 1e-9;\n\tlet low = 0;\n\tlet high = Math.max(daysBetween(from, to), 0);\n\tconst origin = startOfDay(from);\n\twhile (low < high) {\n\t\tconst middle = Math.floor((low + high) / 2);\n\t\tif (plannedPercentAt(tasks, addDays(origin, middle)) >= percent - tolerance) high = middle;\n\t\telse low = middle + 1;\n\t}\n\treturn addDays(origin, low);\n}\n\n/**\n * How far reported completion trails or leads the plan, in calendar days: the\n * gap between `asOf` and the day the plan expected to stand where the work\n * actually stands. Positive is behind. A percentage gap reads differently at\n * every point of an S-curve; a gap in days means the same thing throughout.\n */\nexport function scheduleVarianceDays(\n\ttasks: readonly SCurveTask[],\n\tasOf: Date,\n\tearned: number\n): null | number {\n\tconst scheduled = tasks.filter(isSchedulable);\n\tif (scheduled.length === 0) return null;\n\tconst plannedStart = new Date(Math.min(...scheduled.map((task) => task.start.getTime())));\n\tconst plannedFinish = new Date(Math.max(...scheduled.map((task) => task.end.getTime())));\n\tconst plannedAsOf = plannedPercentAt(scheduled, asOf);\n\n\tif (earned < plannedAsOf) {\n\t\tconst reached = firstDayPlannedAt(scheduled, earned, plannedStart, asOf);\n\t\treturn Math.max(daysBetween(reached, asOf), 0);\n\t}\n\tif (asOf.getTime() >= plannedFinish.getTime()) return 0;\n\tconst reached = firstDayPlannedAt(scheduled, earned, asOf, plannedFinish);\n\treturn -Math.max(daysBetween(asOf, reached), 0);\n}\n\n/** Bucket boundaries covering the schedule, with both endpoints kept exact. */\nexport function buildCurveDates(\n\tscale: SCurveScale,\n\tstart: Date,\n\tend: Date,\n\textra: readonly Date[] = []\n): Date[] {\n\tconst dates: Date[] = [start];\n\tlet cursor = nextBucket(scale, startOfBucket(scale, start));\n\twhile (cursor.getTime() < end.getTime() && dates.length < MAX_POINTS) {\n\t\tdates.push(cursor);\n\t\tcursor = nextBucket(scale, cursor);\n\t}\n\tif (end.getTime() > start.getTime()) dates.push(end);\n\tfor (const date of extra) {\n\t\tif (Number.isNaN(date.getTime())) continue;\n\t\tif (date.getTime() <= start.getTime() || date.getTime() >= end.getTime()) continue;\n\t\tdates.push(date);\n\t}\n\tconst unique = new Map(dates.map((date) => [date.getTime(), date]));\n\treturn [...unique.values()].toSorted((left, right) => left.getTime() - right.getTime());\n}\n\n/**\n * Build the planned and actual cumulative curves. Planned comes from the\n * schedule alone. Actual is rebuilt from each task's recorded readings and\n * weighted like the plan; without readings it is only the as-of completion, a\n * single measured point rather than a line drawn through unrecorded time.\n */\nexport function buildSCurve(\n\ttasks: readonly SCurveTask[],\n\toptions: {\n\t\tasOf?: Date;\n\t\tscale: SCurveScale;\n\t}\n): SCurveSeries {\n\tconst scheduled = tasks.filter(isSchedulable);\n\tconst asOf = options.asOf && !Number.isNaN(options.asOf.getTime()) ? options.asOf : new Date();\n\tconst earned = earnedPercent(scheduled);\n\tconst actualSource = scheduled.some((task) => task.readings !== undefined) ? 'history' : 'as-of';\n\n\tif (scheduled.length === 0) {\n\t\treturn {\n\t\t\tactualSource,\n\t\t\tearnedPercent: earned,\n\t\t\tplannedFinish: null,\n\t\t\tplannedPercentAsOf: 0,\n\t\t\tplannedStart: null,\n\t\t\tpoints: [],\n\t\t\tscheduleVarianceDays: null\n\t\t};\n\t}\n\n\tconst plannedStart = new Date(Math.min(...scheduled.map((task) => task.start.getTime())));\n\tconst plannedFinish = new Date(Math.max(...scheduled.map((task) => task.end.getTime())));\n\tconst readingTimes = scheduled\n\t\t.flatMap((task) => task.readings ?? [])\n\t\t.map((reading) => reading.date.getTime())\n\t\t.filter((time) => !Number.isNaN(time));\n\n\t// Work reported before the plan said it would start, or after it said it would\n\t// finish, still belongs on the curve. Unfinished work also runs the curve on to\n\t// today, so an overrunning project shows its actual line stalling past the plan.\n\tconst curveStart = new Date(Math.min(plannedStart.getTime(), ...readingTimes));\n\tconst curveEnd = new Date(\n\t\tMath.max(\n\t\t\tplannedFinish.getTime(),\n\t\t\t...readingTimes,\n\t\t\tearned < 100 ? asOf.getTime() : Number.NEGATIVE_INFINITY\n\t\t)\n\t);\n\tconst dates = buildCurveDates(options.scale, curveStart, curveEnd, [asOf]);\n\tconst points: SCurvePoint[] = dates.map((date) => ({\n\t\tactual:\n\t\t\tdate.getTime() > asOf.getTime()\n\t\t\t\t? null\n\t\t\t\t: actualSource === 'history'\n\t\t\t\t\t? actualPercentAt(scheduled, date)\n\t\t\t\t\t: date.getTime() === asOf.getTime()\n\t\t\t\t\t\t? earned\n\t\t\t\t\t\t: null,\n\t\tdate,\n\t\tplanned: plannedPercentAt(scheduled, date)\n\t}));\n\n\treturn {\n\t\tactualSource,\n\t\tearnedPercent: earned,\n\t\tplannedFinish,\n\t\tplannedPercentAsOf: plannedPercentAt(scheduled, asOf),\n\t\tplannedStart,\n\t\tpoints,\n\t\tscheduleVarianceDays: scheduleVarianceDays(scheduled, asOf, earned)\n\t};\n}\n",
			"type": "registry:file",
			"target": "s-curve/s-curve-lib.ts"
		},
		{
			"content": "export type SCurveScale = 'month' | 'quarter' | 'week';\n\n/** A recorded cumulative-progress reading, 0–100, describing a point in time. */\nexport interface SCurveProgressPoint {\n\tdate: Date;\n\tpercent: number;\n}\n\n/**\n * A scheduled item contributing to the curve. `end` is exclusive, matching the\n * Gantt primitive: an all-day task covering July 6–10 passes July 6 as `start`\n * and July 11 as `end`.\n */\nexport interface SCurveTask {\n\tend: Date;\n\tid: string;\n\t/** Reported completion, 0–100. Used for the as-of earned value. */\n\tpercentComplete?: number;\n\t/**\n\t * This task's recorded readings, in the order they were taken. A task's value\n\t * at a date is its latest reading on or before it, and zero before its first.\n\t * Omit on every task when history could not be read: the actual line is then\n\t * reduced to the as-of reading rather than drawn from invented history.\n\t */\n\treadings?: readonly SCurveProgressPoint[];\n\tstart: Date;\n\t/** Relative contribution to the total. Defaults to 1, so tasks weigh equally. */\n\tweight?: number;\n}\n\nexport interface SCurvePoint {\n\t/** Null where progress is not known for that date, so the line stops. */\n\tactual: null | number;\n\tdate: Date;\n\tplanned: number;\n}\n\nexport interface SCurveSeries {\n\t/**\n\t * `history` when per-task readings were supplied and the actual line is\n\t * rebuilt from them; `as-of` when only the current reported completion is\n\t * known, drawn as a single point.\n\t */\n\tactualSource: 'as-of' | 'history';\n\t/** Reported cumulative completion at the as-of date. */\n\tearnedPercent: number;\n\tplannedFinish: Date | null;\n\t/** Planned cumulative completion at the as-of date. */\n\tplannedPercentAsOf: number;\n\tplannedStart: Date | null;\n\tpoints: SCurvePoint[];\n\t/**\n\t * Calendar days between the as-of date and the date the plan expected the\n\t * reported completion. Positive is behind, negative is ahead. Null when\n\t * nothing is schedulable.\n\t */\n\tscheduleVarianceDays: null | number;\n}\n\nexport interface SCurveProps {\n\t/** Reading used for the variance summary and where the actual line ends. */\n\tasOf?: Date;\n\tclass?: string;\n\t/** Defaults to the finest interval that suits the schedule's span. */\n\tdefaultScale?: SCurveScale;\n\tdescription?: string;\n\t/** Context below the chart, such as what the planned line was drawn from. */\n\tfootnote?: string;\n\tloading?: boolean;\n\tlocale?: string;\n\t/** Legend label for the planned line, such as \"Baseline r2\". */\n\tplannedLabel?: string;\n\ttasks: SCurveTask[];\n\ttitle?: string;\n}\n",
			"type": "registry:file",
			"target": "s-curve/s-curve-types.ts"
		},
		{
			"content": "<script lang=\"ts\">\n\timport ChartSplineIcon from '@lucide/svelte/icons/chart-spline';\n\timport CircleCheckIcon from '@lucide/svelte/icons/circle-check';\n\timport InfoIcon from '@lucide/svelte/icons/info';\n\timport TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';\n\timport { Area, LineChart, Points, Spline } from 'layerchart';\n\timport { untrack } from 'svelte';\n\n\timport { Badge } from '$UI$/badge/index.js';\n\timport * as Card from '$UI$/card/index.js';\n\timport * as Chart from '$UI$/chart/index.js';\n\timport * as Empty from '$UI$/empty/index.js';\n\timport * as Select from '$UI$/select/index.js';\n\timport { Skeleton } from '$UI$/skeleton/index.js';\n\n\timport type { SCurvePoint, SCurveProps, SCurveScale } from './s-curve-types.js';\n\n\timport {\n\t\tbuildSCurve,\n\t\tdefaultCurveScale,\n\t\tS_CURVE_SCALES,\n\t\tstartOfBucket\n\t} from './s-curve-lib.js';\n\n\tconst SCALE_LABELS: Readonly<Record<SCurveScale, string>> = {\n\t\tweek: 'Weekly',\n\t\tmonth: 'Monthly',\n\t\tquarter: 'Quarterly'\n\t};\n\n\tlet {\n\t\tasOf = new Date(),\n\t\tclass: className,\n\t\tdefaultScale,\n\t\tdescription = 'Cumulative planned completion against reported progress.',\n\t\tfootnote,\n\t\tloading = false,\n\t\tlocale = 'en-US',\n\t\tplannedLabel = 'Planned',\n\t\ttasks,\n\t\ttitle = 'S-curve'\n\t}: SCurveProps = $props();\n\n\tlet scale = $state<SCurveScale>(untrack(() => defaultScale ?? defaultCurveScale(tasks)));\n\n\tconst curve = $derived(buildSCurve(tasks, { asOf, scale }));\n\tconst chartConfig = $derived({\n\t\t// The plan is the reference, so it stays neutral and the reported line,\n\t\t// which is what the reader came to judge, carries the accent.\n\t\tplanned: { label: plannedLabel, color: 'var(--muted-foreground)' },\n\t\tactual: { label: 'Actual', color: 'var(--chart-2)' }\n\t} satisfies Chart.ChartConfig);\n\n\t/**\n\t * The headline is the gap in days, because a percentage gap means something\n\t * different early in an S-curve than in its steep middle. A few days either\n\t * way is reporting noise rather than a schedule position.\n\t */\n\tconst ON_SCHEDULE_DAYS = 3;\n\tconst status = $derived.by((): { label: string; variant: 'success' | 'warning' } | null => {\n\t\tconst days = curve.scheduleVarianceDays;\n\t\tif (days === null) return null;\n\t\tif (curve.earnedPercent >= 100) return { label: 'Complete', variant: 'success' };\n\t\tif (Math.abs(days) <= ON_SCHEDULE_DAYS) return { label: 'On schedule', variant: 'success' };\n\t\tconst count = `${Math.abs(days)} ${Math.abs(days) === 1 ? 'day' : 'days'}`;\n\t\treturn days > 0\n\t\t\t? { label: `${count} behind`, variant: 'warning' }\n\t\t\t: { label: `${count} ahead`, variant: 'success' };\n\t});\n\n\tconst series = $derived([\n\t\t{\n\t\t\tkey: 'planned',\n\t\t\tlabel: plannedLabel,\n\t\t\tvalue: (point: SCurvePoint) => point.planned,\n\t\t\tcolor: 'var(--color-planned)',\n\t\t\t// A class, not a stroke-width attribute: the primitive's own stylesheet\n\t\t\t// would otherwise win and draw the line as a hairline.\n\t\t\tprops: { class: 'stroke-2 opacity-70 [stroke-linecap:round] [stroke-linejoin:round]' }\n\t\t},\n\t\t{\n\t\t\tkey: 'actual',\n\t\t\tlabel: 'Actual',\n\t\t\tvalue: (point: SCurvePoint) => point.actual,\n\t\t\tcolor: 'var(--color-actual)',\n\t\t\tprops: {\n\t\t\t\tclass: 'stroke-[2.5] [stroke-linecap:round] [stroke-linejoin:round]',\n\t\t\t\tdefined: (point: SCurvePoint) => point.actual !== null\n\t\t\t}\n\t\t}\n\t]);\n\n\t/**\n\t * Where the actual line is marked with a dot. With history, only today's end\n\t * of the line is marked, so the reader finds where reporting stands without\n\t * every bucket boundary competing for attention. Without history the as-of\n\t * reading is the only point, and a single point has no segment to stroke.\n\t */\n\tconst actualPoints = $derived(\n\t\tcurve.points.filter((point) => point.actual !== null && point.date.getTime() === asOf.getTime())\n\t);\n\n\t/**\n\t * The stretch between the two lines up to today, shaded in the status colour\n\t * so a widening or closing gap reads without comparing the lines point by point.\n\t */\n\tconst gapPoints = $derived(curve.points.filter((point) => point.actual !== null));\n\tconst gapClass = $derived(\n\t\tstatus?.variant === 'warning' ? 'fill-warning/10' : 'fill-success/10'\n\t);\n\n\t/** Both values at today, labelled at the marker; the planned label yields when they meet. */\n\tconst LABEL_MIN_GAP = 8;\n\tconst todayPoint = $derived(actualPoints.at(-1) ?? null);\n\tconst showPlannedLabel = $derived(\n\t\ttodayPoint !== null &&\n\t\t\tMath.abs(curve.plannedPercentAsOf - curve.earnedPercent) >= LABEL_MIN_GAP\n\t);\n\n\tfunction formatDay(value: Date | null): string {\n\t\tif (value === null) return '—';\n\t\treturn new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' }).format(\n\t\t\tvalue\n\t\t);\n\t}\n\n\t/** The curve's finish is exclusive, so the last planned working day is one before. */\n\tconst plannedFinishDay = $derived(\n\t\tcurve.plannedFinish === null\n\t\t\t? null\n\t\t\t: new Date(\n\t\t\t\t\tcurve.plannedFinish.getFullYear(),\n\t\t\t\t\tcurve.plannedFinish.getMonth(),\n\t\t\t\t\tcurve.plannedFinish.getDate() - 1\n\t\t\t\t)\n\t);\n\n\tfunction formatPercent(value: number): string {\n\t\treturn `${Math.round(value)}%`;\n\t}\n\n\t/**\n\t * Label the interval boundaries only. The curve also carries the exact schedule\n\t * start, finish, and reading dates as points, and labelling those crowds the\n\t * ends of the axis with dates a few days apart.\n\t */\n\tconst MAX_AXIS_TICKS = 8;\n\tconst axisTicks = $derived.by(() => {\n\t\tconst boundaries = curve.points\n\t\t\t.map((point) => point.date)\n\t\t\t.filter((date) => startOfBucket(scale, date).getTime() === date.getTime());\n\t\tconst candidates =\n\t\t\tboundaries.length > 1 ? boundaries : curve.points.map((point) => point.date);\n\t\t// A short curve on a coarse interval falls back to its exact points, several\n\t\t// of which fall in the same month; label each month once.\n\t\t// Dates are sorted, so a repeated label is always the one just before.\n\t\tconst dates = candidates.filter(\n\t\t\t(date, index) =>\n\t\t\t\tindex === 0 || formatAxisDate(date) !== formatAxisDate(candidates[index - 1])\n\t\t);\n\t\tif (dates.length <= MAX_AXIS_TICKS) return dates;\n\t\tconst step = Math.ceil(dates.length / MAX_AXIS_TICKS);\n\t\treturn dates.filter((_, index) => index % step === 0);\n\t});\n\n\tfunction formatAxisDate(value: Date): string {\n\t\treturn new Intl.DateTimeFormat(\n\t\t\tlocale,\n\t\t\tscale === 'week'\n\t\t\t\t? { day: 'numeric', month: 'short' }\n\t\t\t\t: // A two-digit year reads as a day of the month: \"Oct 26\".\n\t\t\t\t\t{ month: 'short', year: 'numeric' }\n\t\t).format(value);\n\t}\n\n\tfunction formatPointDate(value: unknown): string {\n\t\tif (!(value instanceof Date)) return '';\n\t\treturn new Intl.DateTimeFormat(locale, {\n\t\t\tday: 'numeric',\n\t\t\tmonth: 'short',\n\t\t\tyear: 'numeric'\n\t\t}).format(value);\n\t}\n</script>\n\n{#snippet curveValue(entry: { item: { key?: string }; name: string; value: unknown })}\n\t<!-- The tooltip renders outside the chart container, where its --color-* variables\n\t     are not in scope, so the swatch reads the theme token directly. -->\n\t{@const swatch =\n\t\tentry.item.key === 'actual' ? chartConfig.actual.color : chartConfig.planned.color}\n\t<div class=\"flex w-full items-center gap-2\">\n\t\t<div class=\"size-2.5 shrink-0 rounded-[2px]\" style={`background-color: ${swatch}`}></div>\n\t\t<span class=\"flex-1 text-muted-foreground\">{entry.name}</span>\n\t\t<span class=\"font-mono font-medium text-foreground tabular-nums\">\n\t\t\t{typeof entry.value === 'number' ? formatPercent(entry.value) : '—'}\n\t\t</span>\n\t</div>\n{/snippet}\n\n{#snippet stat(label: string, value: string, emphasis: boolean)}\n\t<div class=\"flex flex-col gap-1 rounded-lg bg-muted/50 px-3 py-2.5\">\n\t\t<dt class=\"text-xs text-muted-foreground\">{label}</dt>\n\t\t<dd class={['text-lg leading-none font-semibold', !emphasis && 'text-muted-foreground']}>\n\t\t\t{value}\n\t\t</dd>\n\t</div>\n{/snippet}\n\n<Card.Root class={className}>\n\t<Card.Header>\n\t\t<Card.Title class=\"flex flex-wrap items-center gap-2\">\n\t\t\t{title}\n\t\t\t{#if status !== null && !loading}\n\t\t\t\t<Badge variant={status.variant}>\n\t\t\t\t\t{#if status.variant === 'warning'}\n\t\t\t\t\t\t<TriangleAlertIcon />\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<CircleCheckIcon />\n\t\t\t\t\t{/if}\n\t\t\t\t\t{status.label}\n\t\t\t\t</Badge>\n\t\t\t{/if}\n\t\t</Card.Title>\n\t\t<Card.Description>{description}</Card.Description>\n\t\t<Card.Action>\n\t\t\t<Select.Root type=\"single\" bind:value={scale}>\n\t\t\t\t<Select.Trigger size=\"sm\" class=\"w-32\" aria-label=\"Curve interval\">\n\t\t\t\t\t{SCALE_LABELS[scale]}\n\t\t\t\t</Select.Trigger>\n\t\t\t\t<Select.Content>\n\t\t\t\t\t<Select.Group>\n\t\t\t\t\t\t{#each S_CURVE_SCALES as item (item)}\n\t\t\t\t\t\t\t<Select.Item value={item}>{SCALE_LABELS[item]}</Select.Item>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</Select.Group>\n\t\t\t\t</Select.Content>\n\t\t\t</Select.Root>\n\t\t</Card.Action>\n\t</Card.Header>\n\t<Card.Content class=\"flex flex-col gap-4\">\n\t\t{#if curve.points.length > 0}\n\t\t\t<dl class=\"grid grid-cols-2 gap-2 sm:grid-cols-3\">\n\t\t\t\t{@render stat('Reported', formatPercent(curve.earnedPercent), true)}\n\t\t\t\t{@render stat('Planned by today', formatPercent(curve.plannedPercentAsOf), false)}\n\t\t\t\t<div class=\"col-span-2 sm:col-span-1\">\n\t\t\t\t\t{@render stat('Planned finish', formatDay(plannedFinishDay), false)}\n\t\t\t\t</div>\n\t\t\t</dl>\n\t\t{/if}\n\t\t{#if loading}\n\t\t\t<Skeleton class=\"h-72 w-full md:h-96\" aria-label=\"Loading progress curve\" />\n\t\t{:else if curve.points.length === 0}\n\t\t\t<Empty.Root class=\"rounded-xl border border-dashed\">\n\t\t\t\t<Empty.Header>\n\t\t\t\t\t<Empty.Media variant=\"icon\"><ChartSplineIcon /></Empty.Media>\n\t\t\t\t\t<Empty.Title>No dated work to plot</Empty.Title>\n\t\t\t\t\t<Empty.Description>\n\t\t\t\t\t\tAn S-curve needs tasks with planned start and finish dates.\n\t\t\t\t\t</Empty.Description>\n\t\t\t\t</Empty.Header>\n\t\t\t</Empty.Root>\n\t\t{:else}\n\t\t\t<Chart.Container config={chartConfig} class=\"aspect-auto h-72 w-full md:h-96\">\n\t\t\t\t<LineChart\n\t\t\t\t\tdata={curve.points}\n\t\t\t\t\tx=\"date\"\n\t\t\t\t\t{series}\n\t\t\t\t\tyDomain={[0, 100]}\n\t\t\t\t\tyPadding={[8, 8]}\n\t\t\t\t\tpadding={{ bottom: 24, left: 40, right: 16, top: 28 }}\n\t\t\t\t\tlegend={{ placement: 'top-right' }}\n\t\t\t\t\tprops={{\n\t\t\t\t\t\txAxis: { format: formatAxisDate, ticks: axisTicks },\n\t\t\t\t\t\tyAxis: { format: formatPercent, ticks: [0, 25, 50, 75, 100] }\n\t\t\t\t\t}}\n\t\t\t\t\tannotations={[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'line',\n\t\t\t\t\t\t\tx: asOf,\n\t\t\t\t\t\t\tlabel: 'Today',\n\t\t\t\t\t\t\tlabelPlacement: 'top',\n\t\t\t\t\t\t\tprops: {\n\t\t\t\t\t\t\t\tline: { class: 'stroke-foreground/25 [stroke-dasharray:3_3]' },\n\t\t\t\t\t\t\t\tlabel: { class: 'fill-muted-foreground text-[11px] font-medium' }\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t{#snippet marks({\n\t\t\t\t\t\tcontext\n\t\t\t\t\t}: {\n\t\t\t\t\t\tcontext: {\n\t\t\t\t\t\t\tseries: { visibleSeries: { key: string }[] };\n\t\t\t\t\t\t\txScale: (value: Date) => number;\n\t\t\t\t\t\t\tyScale: (value: number) => number;\n\t\t\t\t\t\t};\n\t\t\t\t\t})}\n\t\t\t\t\t\t{#if gapPoints.length > 1}\n\t\t\t\t\t\t\t<Area\n\t\t\t\t\t\t\t\tdata={gapPoints}\n\t\t\t\t\t\t\t\ty0={(point: SCurvePoint) => point.actual}\n\t\t\t\t\t\t\t\ty1={(point: SCurvePoint) => point.planned}\n\t\t\t\t\t\t\t\tclass={gapClass}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t{#each context.series.visibleSeries as item (item.key)}\n\t\t\t\t\t\t\t<Spline seriesKey={item.key} />\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t<Points\n\t\t\t\t\t\t\tdata={actualPoints}\n\t\t\t\t\t\t\ty={(point: SCurvePoint) => point.actual}\n\t\t\t\t\t\t\tr={4.5}\n\t\t\t\t\t\t\tclass=\"fill-(--color-actual) stroke-background stroke-2\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{#if todayPoint !== null && todayPoint.actual !== null}\n\t\t\t\t\t\t\t{@const x = context.xScale(todayPoint.date) - 8}\n\t\t\t\t\t\t\t<text\n\t\t\t\t\t\t\t\t{x}\n\t\t\t\t\t\t\t\ty={context.yScale(todayPoint.actual) - 10}\n\t\t\t\t\t\t\t\ttext-anchor=\"end\"\n\t\t\t\t\t\t\t\tclass=\"fill-foreground text-xs font-semibold tabular-nums\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatPercent(todayPoint.actual)}\n\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t\t{#if showPlannedLabel}\n\t\t\t\t\t\t\t\t<text\n\t\t\t\t\t\t\t\t\t{x}\n\t\t\t\t\t\t\t\t\ty={context.yScale(todayPoint.planned) - 10}\n\t\t\t\t\t\t\t\t\ttext-anchor=\"end\"\n\t\t\t\t\t\t\t\t\tclass=\"fill-muted-foreground text-xs font-medium tabular-nums\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{formatPercent(todayPoint.planned)}\n\t\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t{/snippet}\n\t\t\t\t\t{#snippet tooltip()}\n\t\t\t\t\t\t<Chart.Tooltip indicator=\"line\" labelFormatter={formatPointDate} formatter={curveValue} />\n\t\t\t\t\t{/snippet}\n\t\t\t\t</LineChart>\n\t\t\t</Chart.Container>\n\t\t{/if}\n\n\t\t{#if (!loading && curve.actualSource === 'as-of' && curve.points.length > 0) || footnote}\n\t\t\t<div class=\"flex flex-col gap-1 border-t pt-3 text-xs text-muted-foreground\">\n\t\t\t\t{#if !loading && curve.actualSource === 'as-of' && curve.points.length > 0}\n\t\t\t\t\t<p class=\"flex gap-1.5\">\n\t\t\t\t\t\t<InfoIcon class=\"mt-px size-3.5 shrink-0\" />\n\t\t\t\t\t\tProgress history could not be read, so only today's reported completion is plotted.\n\t\t\t\t\t</p>\n\t\t\t\t{/if}\n\t\t\t\t{#if footnote}\n\t\t\t\t\t<p class=\"flex gap-1.5\">\n\t\t\t\t\t\t<InfoIcon class=\"mt-px size-3.5 shrink-0\" />\n\t\t\t\t\t\t{footnote}\n\t\t\t\t\t</p>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\t</Card.Content>\n</Card.Root>\n",
			"type": "registry:file",
			"target": "s-curve/s-curve.svelte"
		}
	]
}