WORKSPACE.PM
ProjektEmpfohlen 4 × 9ProjektleitungProjektteam

Meilenstein-Countdown

Listet die nächsten Meilensteine mit Restlaufzeit in Tagen und hebt überfällige mit ihrem Verzug hervor.

Zum Code
NÄCHSTE MEILENSTEINE5-4 T+3 T+11 T+24 T+40 Tüberfälligfälligim Plan

Nachbau im Stil dieser Website. Die echte Kachel übernimmt Schriften und Farbschema Ihres Mandanten.

Wofür das gut ist

Die häufigste Frage im Jour fixe lautet „was steht als Nächstes an", und die häufigste Antwort ist ein Sprung in den Terminplan. Diese Kachel spart den Sprung: die nächsten Meilensteine, jeder mit Restlaufzeit in Tagen, überfällige zuoberst und mit ihrem Verzug beziffert. Den Status leitet der Server ab - erledigt bei hundert Prozent Fortschritt, überfällig bei vergangenem Plantermin, sonst im Plan.

Was Sie in der Kachel sehen

  • Je Zeile ein Meilenstein mit Bezeichnung und Plantermin.
  • Rechts die Restlaufzeit in Tagen; ein negativer Wert ist der bereits aufgelaufene Verzug.
  • Die Farbe unterscheidet überfällig, demnächst fällig und im Plan.

Der vollständige Code

Eigenständig und ohne externe Bibliothek. In den Editor einfügen, speichern, fertig.

TypeScript130 Zeilen
Rohdatei
// Meilenstein-Countdown
// Quelle: widget.query("project.milestones")
// Status wird vom Server abgeleitet: completed (Fortschritt 100 %),
// overdue (Plantermin vergangen), sonst on-track.

const isDark: boolean = widget.data.theme === "dark";
const C = {
  text: isDark ? "#f1f5f9" : "#1e293b",
  sub: "#94a3b8",
  card: isDark ? "#1e293b" : "#f8fafc",
  line: isDark ? "#334155" : "#e2e8f0",
  green: "#22c55e",
  amber: "#f59e0b",
  red: "#ef4444",
  violet: "#8b5cf6",
};

const DAY = 86400000;

function esc(s: string): string {
  return String(s).replace(/[&<>"]/g, (c: string) =>
    c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
}

function note(text: string): string {
  return `<div style="display:flex;align-items:center;justify-content:center;height:100%;min-height:60px;padding:12px;font-size:12px;color:${C.sub};text-align:center">${text}</div>`;
}

function queryError(code: string): string {
  return note(
    code === "forbidden" ? "Keine Berechtigung für die Termindaten."
      : code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
      : "Meilensteine konnten nicht geladen werden.",
  );
}

/** "YYYY-MM-DD" als LOKALE Mitternacht lesen. Date.parse würde reine
 *  Datumsangaben als UTC interpretieren — Tagesdifferenzen kippen dann je
 *  nach Zeitzone um einen Tag. */
function dayStart(iso: string): number {
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || "");
  return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() : NaN;
}

function dayDiff(iso: string): number | null {
  const t: number = dayStart(iso);
  if (Number.isNaN(t)) return null;
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  return Math.round((t - today.getTime()) / DAY);
}

function dateShort(iso: string): string {
  const t: number = dayStart(iso);
  return Number.isNaN(t) ? "—" : new Date(t).toLocaleDateString(navigator.language, {
    day: "2-digit", month: "2-digit", year: "2-digit",
  });
}

widget.el.innerHTML = note("Lade Meilensteine …");

(async () => {
  const res = await widget.query("project.milestones");
  if (!res.ok) {
    widget.el.innerHTML = queryError(res.error.code);
    widget.done();
    return;
  }

  const all = res.data.items;
  if (all.length === 0) {
    widget.el.innerHTML = note("Keine Meilensteine hinterlegt.");
    widget.done();
    return;
  }

  const done = all.filter((m) => m.status === "completed");
  const overdue = all.filter((m) => m.status === "overdue");
  const openList = all
    .filter((m) => m.status !== "completed")
    .slice()
    .sort((a, b) => (Date.parse(a.planDate) || 0) - (Date.parse(b.planDate) || 0));

  const next = openList.filter((m) => m.status !== "overdue")[0];
  const nextIn: number | null = next ? dayDiff(next.planDate) : null;
  const pct: number = Math.round((done.length / all.length) * 100);

  function chip(m: WidgetMilestone): string {
    const d: number | null = dayDiff(m.planDate);
    if (m.status === "overdue") {
      return `<span style="color:${C.red};font-weight:600;white-space:nowrap">${d === null ? "überfällig" : Math.abs(d) + " Tage überfällig"}</span>`;
    }
    if (d === null) return `<span style="color:${C.sub};white-space:nowrap">kein Termin</span>`;
    const color: string = d <= 7 ? C.amber : C.sub;
    return `<span style="color:${color};white-space:nowrap">in ${d} Tagen</span>`;
  }

  const rows: string = openList.slice(0, 7).map((m) => {
    const isGate: boolean = m.type === "quality-gate";
    return `<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid ${C.line};font-size:11px">
      <span style="width:6px;height:6px;flex-shrink:0;background:${m.status === "overdue" ? C.red : C.green};${isGate ? "" : "border-radius:50%"}"></span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(m.name)}${isGate ? ` <span style="color:${C.violet};font-size:9px">GATE</span>` : ""}</span>
      <span style="color:${C.sub};white-space:nowrap">${dateShort(m.planDate)}</span>
      ${chip(m)}
    </div>`;
  }).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:12px;margin-bottom:8px">
        <div style="flex:1">
          <div style="font-size:20px;font-weight:700">${done.length}<span style="font-size:12px;color:${C.sub}">/${all.length}</span></div>
          <div style="font-size:10px;color:${C.sub}">erreicht</div>
        </div>
        <div style="flex:1">
          <div style="font-size:20px;font-weight:700;color:${nextIn !== null && nextIn <= 7 ? C.amber : C.text}">${nextIn === null ? "—" : nextIn}</div>
          <div style="font-size:10px;color:${C.sub}">Tage bis zum nächsten</div>
        </div>
        <div style="flex:1">
          <div style="font-size:20px;font-weight:700;color:${overdue.length > 0 ? C.red : C.green}">${overdue.length}</div>
          <div style="font-size:10px;color:${C.sub}">überfällig</div>
        </div>
      </div>
      <div style="height:5px;background:${C.line};border-radius:3px;overflow:hidden;margin-bottom:8px">
        <div style="height:100%;width:${pct}%;background:${C.green}"></div>
      </div>
      ${rows || note("Alle Meilensteine erreicht.")}
    </div>`;
  widget.done();
})();
  1. 1Widget anlegen
  2. 2Code einfügen
  3. 3Größe setzen und speichern

Bauen Sie Ihre eigene Kachel

Testen Sie WORKSPACE.PM dreißig Tage lang mit allen Funktionen. Die Vorlagen dieser Seite funktionieren im Testmandanten genauso wie in der Produktivumgebung.