ProjectRecommended 4 × 9Project managementProject team

Milestone countdown

Lists the next milestones with their remaining days and highlights overdue ones with the delay they have accumulated.

NEXT MILESTONES5-4 d+3 d+11 d+24 d+40 doverduedueon track

A rebuild in this website's style. The real tile picks up your tenant's fonts and color scheme.

What it is good for

The most common question in a weekly meeting is 'what is next', and the most common answer is a jump into the schedule. This tile saves the jump: the next milestones, each with its remaining days, overdue ones on top and quantified. The status comes from the server - completed at one hundred percent progress, overdue once the planned date has passed, on track otherwise.

What the tile shows you

  • One milestone per row, with its label and planned date.
  • On the right, the remaining days; a negative value is the delay already accumulated.
  • The color distinguishes overdue, due soon and on track.

The complete code

Self-contained, with no external library. Paste it into the editor, save, done.

TypeScript130 lines
Raw file
// 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. 1Create the widget
  2. 2Paste the code
  3. 3Set the size and save

Build your own tile

Try WORKSPACE.PM for thirty days with every feature. The templates on this page work in a trial tenant exactly as they do in production.