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 */}
+