From d1c40d07f759d1ee91f28de74e90be4c97c03250 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sat, 20 Jun 2026 21:52:29 +0200 Subject: [PATCH] feat(timeline): milestone dates, zoom, pan, sorting + profile-gated landing Timeline now models milestones as first-class dated lanes: - Epic gains optional startDate/endDate (parsed mindp:/ekai:-tolerant); undated milestones render as a muted dashed future ghost + INACTIVE badge. - Moving a milestone's first task to In progress stamps its startDate=today in epics.ttl via the pod write backend (setEpicStartDate). - Merged Runtime/Done into one bar: green fill = work done, vertical marker = where elapsed time says we should be (behind/ahead at a glance). - Quarter/Month/Week zoom with a horizontally-scrollable track, frozen label column, prev/Today/next pan, and auto-centring on today. - Lane sorting: by start (default, undated last), by name, by progress. Landing: gate the assistant/partners sections so the default OSS build doesn't advertise Chat or empty partner lists. Callback string moved into the i18n table. Demo seeds (CSS + mind-node) ship three milestones (Discovery/Launch/Audit) so the timeline showcases dated, undated, and completed states. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/seed-demo.ts | 50 ++++++ scripts/seed-mind-node.mjs | 50 ++++++ src/app/login/callback/page.tsx | 3 +- src/app/timeline/page.tsx | 277 +++++++++++++++++++++++++------- src/components/Landing.tsx | 113 +++++++------ src/lib/solid/turtle.ts | 30 ++++ src/lib/solid/writes.ts | 21 +++ src/lib/strings.ts | 42 +++++ 8 files changed, 474 insertions(+), 112 deletions(-) diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 82c0b89..54efb76 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -96,6 +96,7 @@ const STATE_TTL = `@prefix : <#> . wf:tracker :this ; mc:number 1 ; dc:title "Welcome to your project board" ; + mc:epic ; a :Todo , :General ; dct:created "2026-06-01"^^xsd:date ; wf:description """Drag cards between columns to change their state. Everything is stored in your own pod.""" . @@ -104,6 +105,7 @@ const STATE_TTL = `@prefix : <#> . wf:tracker :this ; mc:number 2 ; dc:title "Plan the first milestone" ; + mc:epic ; a :InProgress , :General ; dct:created "2026-06-02"^^xsd:date ; mindp:due "2026-07-01"^^xsd:date ; @@ -113,9 +115,56 @@ const STATE_TTL = `@prefix : <#> . wf:tracker :this ; mc:number 3 ; dc:title "Invite a collaborator" ; + mc:epic ; a :Done , :General ; dct:created "2026-06-03"^^xsd:date ; wf:description """Share the project container via WAC to work together.""" . + +<#TASK-004> + wf:tracker :this ; + mc:number 4 ; + dc:title "Prepare release notes" ; + mc:epic ; + a :Todo , :General ; + dct:created "2026-06-10"^^xsd:date ; + wf:description """Draft the notes for the first launch.""" . + +<#TASK-005> + wf:tracker :this ; + mc:number 5 ; + dc:title "Security audit sign-off" ; + mc:epic ; + a :Done , :General ; + dct:created "2026-06-05"^^xsd:date ; + wf:description """External review of the data-sovereignty model.""" . +`; + +const EPICS_TTL = `@prefix dct: . +@prefix mc: . +@prefix mindp: . +@prefix xsd: . + +<#discovery> a mc:Epic ; + mc:number 1 ; + dct:title "Discovery" ; + mc:status "active" ; + mindp:startDate "2026-02-01"^^xsd:date ; + mindp:endDate "2026-05-15"^^xsd:date ; + dct:description """Research, scoping and a rough plan.""" . + +<#launch> a mc:Epic ; + mc:number 2 ; + dct:title "Launch" ; + mc:status "planned" ; + dct:description """Ship the first release — not started yet.""" . + +<#audit> a mc:Epic ; + mc:number 3 ; + dct:title "Audit" ; + mc:status "active" ; + mindp:startDate "2026-09-01"^^xsd:date ; + mindp:endDate "2026-11-30"^^xsd:date ; + dct:description """Independent security and compliance review.""" . `; const TRACKER_TTL = `@prefix dct: . @@ -153,6 +202,7 @@ async function main() { await put(session, `${PROJECT_ROOT}project.ttl`, PROJECT_TTL, "text/turtle"); await put(session, `${PROJECT_ROOT}tracker/tracker.ttl`, TRACKER_TTL, "text/turtle"); + await put(session, `${PROJECT_ROOT}tracker/epics.ttl`, EPICS_TTL, "text/turtle"); await put(session, `${PROJECT_ROOT}tracker/state.ttl`, STATE_TTL, "text/turtle"); await put(session, `${PROJECT_ROOT}meetings/kickoff.ttl`, MEETING_TTL, "text/turtle"); await put(session, `${PROJECT_ROOT}briefings/2026-06-briefing.md`, BRIEFING_MD, "text/markdown"); diff --git a/scripts/seed-mind-node.mjs b/scripts/seed-mind-node.mjs index 051c77c..0410027 100644 --- a/scripts/seed-mind-node.mjs +++ b/scripts/seed-mind-node.mjs @@ -101,6 +101,7 @@ const STATE_TTL = `@prefix : <#> . wf:tracker :this ; mc:number 1 ; dc:title "Welcome to your project board" ; + mc:epic ; a :Todo , :General ; dct:created "2026-06-01"^^xsd:date ; wf:description """Drag cards between columns to change their state. Everything is stored in your own pod.""" . @@ -109,6 +110,7 @@ const STATE_TTL = `@prefix : <#> . wf:tracker :this ; mc:number 2 ; dc:title "Plan the first milestone" ; + mc:epic ; a :InProgress , :General ; dct:created "2026-06-02"^^xsd:date ; mindp:due "2026-07-01"^^xsd:date ; @@ -118,9 +120,56 @@ const STATE_TTL = `@prefix : <#> . wf:tracker :this ; mc:number 3 ; dc:title "Invite a collaborator" ; + mc:epic ; a :Done , :General ; dct:created "2026-06-03"^^xsd:date ; wf:description """Share the project container via WAC to work together.""" . + +<#TASK-004> + wf:tracker :this ; + mc:number 4 ; + dc:title "Prepare release notes" ; + mc:epic ; + a :Todo , :General ; + dct:created "2026-06-10"^^xsd:date ; + wf:description """Draft the notes for the first launch.""" . + +<#TASK-005> + wf:tracker :this ; + mc:number 5 ; + dc:title "Security audit sign-off" ; + mc:epic ; + a :Done , :General ; + dct:created "2026-06-05"^^xsd:date ; + wf:description """External review of the data-sovereignty model.""" . +`; + +const EPICS_TTL = `@prefix dct: . +@prefix mc: . +@prefix mindp: . +@prefix xsd: . + +<#discovery> a mc:Epic ; + mc:number 1 ; + dct:title "Discovery" ; + mc:status "active" ; + mindp:startDate "2026-02-01"^^xsd:date ; + mindp:endDate "2026-05-15"^^xsd:date ; + dct:description """Research, scoping and a rough plan.""" . + +<#launch> a mc:Epic ; + mc:number 2 ; + dct:title "Launch" ; + mc:status "planned" ; + dct:description """Ship the first release — not started yet.""" . + +<#audit> a mc:Epic ; + mc:number 3 ; + dct:title "Audit" ; + mc:status "active" ; + mindp:startDate "2026-09-01"^^xsd:date ; + mindp:endDate "2026-11-30"^^xsd:date ; + dct:description """Independent security and compliance review.""" . `; const TRACKER_TTL = `@prefix dct: . @@ -150,6 +199,7 @@ pod under \`briefings/\`. Owners can publish drafts from \`briefings/drafts/\`. await put(`${ROOT}project.ttl`, PROJECT_TTL, "text/turtle"); await put(`${ROOT}tracker/tracker.ttl`, TRACKER_TTL, "text/turtle"); +await put(`${ROOT}tracker/epics.ttl`, EPICS_TTL, "text/turtle"); await put(`${ROOT}tracker/state.ttl`, STATE_TTL, "text/turtle"); await put(`${ROOT}meetings/kickoff.ttl`, MEETING_TTL, "text/turtle"); await put(`${ROOT}briefings/2026-06-briefing.md`, BRIEFING_MD, "text/markdown"); diff --git a/src/app/login/callback/page.tsx b/src/app/login/callback/page.tsx index 27efa1f..d0e0eca 100644 --- a/src/app/login/callback/page.tsx +++ b/src/app/login/callback/page.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { consumeRestoredPath, ensureSession } from "@/lib/solid/auth"; +import { t } from "@/lib/strings"; export default function LoginCallback() { const router = useRouter(); @@ -11,7 +12,7 @@ export default function LoginCallback() { }, [router]); return (
- Anmeldung wird abgeschlossen… + {t.loginCompleting}
); } diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx index 2cc5e4a..1150498 100644 --- a/src/app/timeline/page.tsx +++ b/src/app/timeline/page.tsx @@ -6,8 +6,9 @@ // beam. Header compares elapsed runtime against actual completion. // Clicking any item opens its detail sheet IN PLACE (?issue= / ?ap= / ?m=). -import { Suspense, useCallback, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { Card, CardContent, @@ -28,6 +29,7 @@ import { t, dateLocale } from "@/lib/strings"; import type { Tracker, Meeting, Issue } from "@/lib/solid/turtle"; const DAY = 86400000; +const LABEL_W = 176; // frozen lane-label column width in px (Tailwind w-44 = 11rem) /* same-lane dots that land on (nearly) the same date get pushed onto one of three vertical slots so none of them hides another */ @@ -54,6 +56,9 @@ function Timeline() { const canEdit = hub.role === "Owner" || hub.role === "Member"; const [tracker, setTracker] = useState(null); const [meetings, setMeetings] = useState(null); + const [view, setView] = useState<"quarter" | "month" | "week">("month"); + const [sort, setSort] = useState<"start" | "name" | "progress">("start"); + const scrollRef = useRef(null); const refresh = useCallback(() => { loadTracker().then(setTracker).catch(() => setTracker(null)); @@ -63,6 +68,21 @@ function Timeline() { loadMeetings().then(setMeetings).catch(() => setMeetings(null)); }, [refresh]); + // re-center on "today" whenever the zoom changes or data first loads, so the + // wider month/week views don't open stranded at the project start. + useEffect(() => { + const el = scrollRef.current; + if (!el || !tracker || !meetings) return; + const a = new Date(`${hub.project.startDate}T00:00:00Z`).getTime(); + const b = new Date(`${hub.project.endDate}T23:59:59Z`).getTime(); + const frac = Math.min(1, Math.max(0, (Date.now() - a) / (b - a))); + const id = requestAnimationFrame(() => { + const trackPx = el.scrollWidth - LABEL_W; + el.scrollLeft = Math.max(0, LABEL_W + frac * trackPx - el.clientWidth / 2); + }); + return () => cancelAnimationFrame(id); + }, [view, tracker, meetings, hub.project.startDate, hub.project.endDate]); + // in-place detail sheets — items open here, not on another page const replaceParam = (key: "issue" | "ap" | "m", id: string | null) => router.replace(id ? `/timeline?${key}=${id}` : "/timeline", { scroll: false }); @@ -92,27 +112,80 @@ function Timeline() { const donePct = active.length ? (doneCount / active.length) * 100 : 0; const weeksLeft = Math.max(0, Math.ceil((t1 - Date.now()) / (7 * DAY))); - // month gridlines - const months: { label: string; pos: number }[] = []; - for (let d = new Date(t0); d.getTime() <= t1; d.setUTCMonth(d.getUTCMonth() + 1)) { - const first = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); - if (first.getTime() < t0) continue; - months.push({ - label: first.toLocaleString(dateLocale, { month: "short", timeZone: "UTC" }), - pos: ((first.getTime() - t0) / (t1 - t0)) * 100, - }); + // gridline ticks at the chosen granularity (quarter / month / week) + const span = t1 - t0; + const ticks: { label: string; pos: number }[] = []; + if (view === "quarter") { + for (let d = new Date(t0); d.getTime() <= t1; d.setUTCMonth(d.getUTCMonth() + 1)) { + if (d.getUTCMonth() % 3 !== 0) continue; + const first = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); + if (first.getTime() < t0) continue; + ticks.push({ label: `Q${Math.floor(d.getUTCMonth() / 3) + 1}`, pos: ((first.getTime() - t0) / span) * 100 }); + } + } else if (view === "week") { + const d = new Date(t0); + while (d.getUTCDay() !== 1) d.setUTCDate(d.getUTCDate() + 1); // snap to first Monday + for (; d.getTime() <= t1; d.setUTCDate(d.getUTCDate() + 7)) { + ticks.push({ + label: d.toLocaleString(dateLocale, { day: "2-digit", month: "short", timeZone: "UTC" }), + pos: ((d.getTime() - t0) / span) * 100, + }); + } + } else { + for (let d = new Date(t0); d.getTime() <= t1; d.setUTCMonth(d.getUTCMonth() + 1)) { + const first = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); + if (first.getTime() < t0) continue; + ticks.push({ label: first.toLocaleString(dateLocale, { month: "short", timeZone: "UTC" }), pos: ((first.getTime() - t0) / span) * 100 }); + } } + // wider track at finer zooms forces horizontal scroll inside the pane + const trackWidthPct = view === "quarter" ? 100 : view === "month" ? 180 : 520; + const panBy = (dir: number) => { + const el = scrollRef.current; + if (el) el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: "smooth" }); + }; + const panToday = () => { + const el = scrollRef.current; + if (!el) return; + const trackPx = el.scrollWidth - LABEL_W; + el.scrollTo({ left: Math.max(0, LABEL_W + (todayX / 100) * trackPx - el.clientWidth / 2), behavior: "smooth" }); + }; + + const ms = (d: string) => new Date(`${d}T12:00:00Z`).getTime(); const lanes = tracker.epics.map((epic) => { const tasks = tracker.issues.filter((i) => i.epic === epic.id); - const dates = tasks + // Explicit milestone dates win; otherwise fall back to the span of task dates. + const taskDates = tasks .flatMap((i) => [i.created, i.due, i.modified]) .filter((d): d is string => !!d) - .map((d) => new Date(`${d}T12:00:00Z`).getTime()) + .map(ms) .filter((t) => t >= t0 - 30 * DAY && t <= t1 + 30 * DAY); - const from = dates.length ? Math.max(t0, Math.min(...dates)) : null; - const to = dates.length ? Math.min(t1, Math.max(...dates)) : null; - return { epic, tasks, from, to }; + const rawFrom = epic.startDate ? ms(epic.startDate) : null; + const rawTo = epic.endDate ? ms(epic.endDate) : null; + // A milestone is "started" once it has an explicit start date; until then it + // shows as inactive/future (it gets stamped when its first task → in progress). + const started = rawFrom !== null; + const from = started ? Math.max(t0, rawFrom!) : null; + const to = rawTo !== null ? Math.min(t1, rawTo) : started ? t1 : null; + // future ghost span for unstarted milestones: from today (or its target end + // back-window) to its deadline, or the rest of the project if undated. + const ghostFrom = Math.max(t0, Math.min(t1, Math.max(Date.now(), t0))); + const ghostTo = rawTo !== null ? Math.min(t1, rawTo) : t1; + return { epic, tasks, from, to, started, ghostFrom, ghostTo, taskDates }; + }); + + // lane ordering — default by start date (unstarted/future milestones last) + const pctOf = (l: (typeof lanes)[number]) => + l.tasks.length ? l.tasks.filter((i) => i.state === "done").length / l.tasks.length : 0; + const sortedLanes = [...lanes].sort((a, b) => { + if (sort === "name") return a.epic.title.localeCompare(b.epic.title, dateLocale); + if (sort === "progress") return pctOf(b) - pctOf(a) || a.epic.number - b.epic.number; + // "start": earliest first, undated milestones sink to the bottom + if (a.from === null && b.from === null) return a.epic.number - b.epic.number; + if (a.from === null) return 1; + if (b.from === null) return -1; + return a.from - b.from || a.epic.number - b.epic.number; }); const dotClass = (i: Issue) => @@ -131,65 +204,126 @@ function Timeline() { {hub.project.startDate} – {hub.project.endDate} · {t.weeksLeft(weeksLeft)}

- {/* plan vs. reality at a glance */} -
-
- {t.runtime} - - - {Math.round(elapsedPct)}% - + {/* plan vs. reality in one bar: the fill is work actually done; the + vertical marker is where elapsed time says we should be. Fill behind + the marker = behind schedule, ahead of it = ahead of schedule. */} +
+
+ {t.doneLabel} + {Math.round(donePct)}%
-
- {t.doneLabel} +
- - {Math.round(donePct)}% - + {/* schedule line — today's expected progress from elapsed runtime */} +
+ + {t.runtime} {Math.round(elapsedPct)}% + +
- -
- {months.map((m) => ( - + {/* zoom granularity + horizontal navigation */} +
+
+ {([["quarter", t.zoomQuarter], ["month", t.zoomMonth], ["week", t.zoomWeek]] as const).map( + ([v, label]) => ( + + ), + )} +
+ +
+ + + +
-
- {/* past shading, gridlines + today beam spanning all lanes */} -
+ {/* horizontally scrollable track — the label column stays frozen */} +
+
+
+
+
+ {ticks.map((m) => ( + + {m.label} + + ))} + {todayInRange && ( + + {t.today} + + )} +
+
+ +
+ {/* past shading, gridlines + today beam spanning all lanes */} +
{todayInRange && (
)} - {months.map((m) => ( + {ticks.map((m) => (
- {lanes.map(({ epic, tasks, from, to }, laneIdx) => { + {sortedLanes.map(({ epic, tasks, from, to, started, ghostFrom, ghostTo }, laneIdx) => { const laneDone = tasks.filter((i) => i.state === "done").length; const lanePct = tasks.length ? (laneDone / tasks.length) * 100 : 0; return (
- {from !== null && to !== null && to > from && ( + {started && from !== null && to !== null && to > from ? (
- )} + ) : !started ? ( + // inactive / future milestone — a muted dashed ghost in the + // future, clearly not yet scheduled (item 6) +
+ ) : null} {slotted(tasks, x).map(({ issue: i, xi, slot }) => ( @@ -274,10 +424,10 @@ function Timeline() { {/* meetings lane */}
-
+

{t.cmdMeetings}

@@ -313,14 +463,17 @@ function Timeline() { })}
+
+
-
+
{t.legendDue} {t.legendOverdue} {t.legendDone} {t.legendMeeting} {t.legendToday} + {t.legendInactive}
diff --git a/src/components/Landing.tsx b/src/components/Landing.tsx index 9e0ff55..49d128c 100644 --- a/src/components/Landing.tsx +++ b/src/components/Landing.tsx @@ -22,8 +22,12 @@ import { profile } from "@/lib/profile"; import { t } from "@/lib/strings"; // Mirrors Shell's nav (same icons) — what the hub gives you once you're in. +// The Chat card only appears when the assistant is enabled (it's the only nav +// surface that's profile-gated), so the default OSS build doesn't advertise it. const FEATURES: { icon: LucideIcon; label: string; blurb: string }[] = [ - { icon: MessageSquare, label: "Chat", blurb: t.landingFeatChatBlurb(profile.assistantName) }, + ...(profile.assistant + ? [{ icon: MessageSquare, label: "Chat", blurb: t.landingFeatChatBlurb(profile.assistantName) }] + : []), { icon: SquareKanban, label: "Board", blurb: t.landingFeatBoardBlurb }, { icon: GanttChartSquare, label: "Timeline", blurb: t.landingFeatTimelineBlurb }, { icon: CalendarDays, label: t.cmdMeetings, blurb: t.landingFeatMeetingsBlurb }, @@ -31,8 +35,13 @@ const FEATURES: { icon: LucideIcon; label: string; blurb: string }[] = [ { icon: Users, label: t.wsTeam, blurb: t.landingFeatTeamBlurb }, ]; -// Trust signals, mirrored from emai.dev's hero badges. -const SIGNALS = [t.landingSignalOss, t.landingSignalSovereign, t.landingSignalNoTracking]; +// Trust signals, mirrored from emai.dev's hero badges. The "open-source AI" badge +// only shows when the assistant is actually on. +const SIGNALS = [ + ...(profile.assistant ? [t.landingSignalOss] : []), + t.landingSignalSovereign, + t.landingSignalNoTracking, +]; export function Landing({ onLoggedIn }: { onLoggedIn?: () => void }) { // Host-resolved public branding (Landing renders only client-side, after the @@ -112,15 +121,17 @@ export function Landing({ onLoggedIn }: { onLoggedIn?: () => void }) { ))}
- {/* partners */} -
- {brand.partners.map((p, i) => ( - - {i > 0 && ×} - {p} - - ))} -
+ {/* partners — only when the deployment actually lists any */} + {brand.partners.length > 0 && ( +
+ {brand.partners.map((p, i) => ( + + {i > 0 && ×} + {p} + + ))} +
+ )}
@@ -128,29 +139,31 @@ export function Landing({ onLoggedIn }: { onLoggedIn?: () => void }) {
- {/* what is the assistant */} -
- -

- {t.landingMeet(profile.assistantName)} -

-
-

- {t.landingMeetP1(profile.assistantName)} -

-

- - - {t.landingAccountWord(profile.appName)}{" "} - {t.landingAccountP2} - -

-
-
+ {/* what is the assistant — only when the assistant is enabled */} + {profile.assistant && ( +
+ +

+ {t.landingMeet(profile.assistantName)} +

+
+

+ {t.landingMeetP1(profile.assistantName)} +

+

+ + + {t.landingAccountWord(profile.appName)}{" "} + {t.landingAccountP2} + +

+
+
+ )} {/* features */}
- +

{t.landingAllInOne}

@@ -173,23 +186,25 @@ export function Landing({ onLoggedIn }: { onLoggedIn?: () => void }) {
- {/* partners */} -
- -

- {t.landingBuiltTogether} -

-
- {brand.partners.map((p) => ( - - {p} - - ))} -
-
+ {/* partners — only when the deployment actually lists any */} + {brand.partners.length > 0 && ( +
+ +

+ {t.landingBuiltTogether} +

+
+ {brand.partners.map((p) => ( + + {p} + + ))} +
+
+ )}