12 Commits

Author SHA1 Message Date
Hartmut 55b861cb43 ci: retrigger — verify actions clone from internal Gitea mirror 2026-05-22 17:07:29 +02:00
Hartmut 184efee0e1 Merge pull request 'ci: increase timeouts + pnpm store cache to reduce QNAP runner flakes' (#66) from ci/reduce-qnap-flakes into main
CI / Architecture Guardrails (push) Successful in 3m42s
CI / Assistant Split Regression (push) Successful in 4m39s
CI / Lint (push) Successful in 5m1s
CI / Typecheck (push) Successful in 5m6s
CI / Unit Tests (push) Successful in 6m58s
CI / Build (push) Successful in 6m49s
CI / E2E Tests (push) Successful in 4m50s
CI / Fresh-Linux Docker Deploy (push) Successful in 5m9s
CI / Release Images (push) Successful in 6m48s
ci: increase timeouts + pnpm store cache to reduce QNAP runner flakes
2026-05-22 15:13:30 +02:00
Hartmut 5db6e24ee0 ci: increase timeouts + pnpm store cache to reduce QNAP runner flakes
CI / Architecture Guardrails (pull_request) Successful in 3m48s
CI / Assistant Split Regression (pull_request) Successful in 5m11s
CI / Typecheck (pull_request) Successful in 5m42s
CI / Lint (pull_request) Successful in 5m46s
CI / Build (pull_request) Successful in 7m54s
CI / E2E Tests (pull_request) Successful in 5m35s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 5m53s
CI / Unit Tests (pull_request) Successful in 5m43s
CI / Release Images (pull_request) Has been skipped
Three distinct flake types addressed:
- pnpm store cache: faster installs reduce total job time
- E2E webServer timeout: 3m → 5m (QNAP cold-start is slow)
- Docker deploy health poll: 3m → 5m headroom for image build
- Pre-pull node:20-bookworm-slim: warms Docker layer cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:50:52 +02:00
Hartmut eb1875e524 Merge pull request 'feat(timeline): start at today + infinite scroll into the past' (#65) from feature/timeline-past-scroll into main
CI / Architecture Guardrails (push) Successful in 3m43s
CI / Lint (push) Successful in 4m31s
CI / Typecheck (push) Successful in 4m33s
CI / Assistant Split Regression (push) Successful in 4m53s
CI / Build (push) Successful in 8m5s
CI / E2E Tests (push) Successful in 8m48s
CI / Fresh-Linux Docker Deploy (push) Successful in 8m41s
CI / Unit Tests (push) Successful in 5m17s
CI / Release Images (push) Has been cancelled
feat(timeline): start at today + infinite scroll into the past
2026-05-22 11:43:07 +02:00
Hartmut 12044f638e ci: retrigger — E2E webServer timeout on run #172 (QNAP runner flake)
CI / Typecheck (pull_request) Successful in 3m25s
CI / Architecture Guardrails (pull_request) Successful in 3m44s
CI / Lint (pull_request) Successful in 2m8s
CI / Assistant Split Regression (pull_request) Successful in 3m44s
CI / Unit Tests (pull_request) Successful in 7m29s
CI / Build (pull_request) Successful in 6m52s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 3m53s
CI / E2E Tests (pull_request) Successful in 20m24s
CI / Release Images (pull_request) Has been skipped
2026-05-22 09:34:10 +02:00
Hartmut 6bc23b3407 Merge pull request 'rename(cleanup): drop last capakraken strings from UI, scripts, schema, tests' (#64) from rename/nexus-final-cleanup into main
CI / Architecture Guardrails (push) Successful in 4m52s
CI / Typecheck (push) Successful in 5m53s
CI / Assistant Split Regression (push) Successful in 9m3s
CI / Lint (push) Successful in 9m39s
CI / Unit Tests (push) Successful in 12m5s
CI / Build (push) Successful in 9m53s
CI / Fresh-Linux Docker Deploy (push) Failing after 8m54s
CI / E2E Tests (push) Successful in 12m55s
CI / Release Images (push) Has been skipped
rename(cleanup): drop last capakraken strings from UI, scripts, schema, tests
2026-05-22 09:13:05 +02:00
Hartmut 2383bcbdc0 fix(timeline): trigger scroll-to-today on isInitialLoading→false not totalCanvasWidth
CI / Architecture Guardrails (pull_request) Successful in 2m53s
CI / Typecheck (pull_request) Successful in 3m28s
CI / Assistant Split Regression (pull_request) Successful in 3m40s
CI / Lint (pull_request) Successful in 4m26s
CI / Unit Tests (pull_request) Successful in 8m36s
CI / Build (pull_request) Successful in 9m47s
CI / E2E Tests (pull_request) Failing after 14m2s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 16m53s
CI / Release Images (pull_request) Has been skipped
totalCanvasWidth is computed from viewStart/viewDays before data loads,
so the previous trigger fired during the loading spinner. scrollLeft
was clipped to 0 (no canvas in DOM yet) and the guard was set, blocking
the real scroll after data arrived. Using isInitialLoading as the dep
fires the effect exactly when the canvas enters the DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:48:23 +02:00
Hartmut 0e9d6ec388 fix(timeline): wait for canvas width before scrolling to today
CI / Assistant Split Regression (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Typecheck (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Architecture Guardrails (pull_request) Has been cancelled
useLayoutEffect([]) fired before isInitialLoading resolved, so the
scroll container had no canvas yet — scrollLeft was clipped to 0.
Now the scroll-to-today fires on the first render where totalCanvasWidth
becomes non-zero. The cleanup effect resets the guard on unmount so
React Strict Mode's fake-unmount+remount also scrolls correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:45:09 +02:00
Hartmut 7285668c52 fix(timeline): use empty-deps useLayoutEffect for mount scroll to today
CI / Architecture Guardrails (pull_request) Successful in 4m53s
CI / Typecheck (pull_request) Successful in 4m55s
CI / Assistant Split Regression (pull_request) Successful in 5m38s
CI / Build (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
The guard-ref approach broke in React Strict Mode (dev): the ref
persisted as `true` across the simulated remount, so the second
invocation skipped the scroll — leaving scrollLeft=0 (today-90
at the left edge, not today). An empty-deps useLayoutEffect runs
twice in Strict Mode but both executions fire against the same
initial `toLeft` and produce the correct result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:38:08 +02:00
Hartmut 944d36bdb2 fix(timeline): pre-load 90-day past buffer + scroll to today on mount
CI / Architecture Guardrails (pull_request) Successful in 5m6s
CI / Typecheck (pull_request) Successful in 7m31s
CI / Assistant Split Regression (pull_request) Successful in 6m45s
CI / Lint (pull_request) Successful in 6m19s
CI / Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
viewStart=today left no canvas to the left of scrollLeft=0, making
left-scroll physically impossible. Now viewStart defaults to today-90
so the canvas always has 90 days to scroll into, and a mount-time
useLayoutEffect positions the viewport with today at the left edge.

The Today button restores this view: scrolls in-range, or resets
viewStart and schedules a post-layout scroll if today has scrolled
out of the visible window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:15:37 +02:00
Hartmut 6ec512e302 test(cron): raise timeout for next/server cold-import on act runner
CI / Architecture Guardrails (pull_request) Successful in 4m22s
CI / Typecheck (pull_request) Successful in 6m48s
CI / Assistant Split Regression (pull_request) Successful in 7m49s
CI / Lint (pull_request) Successful in 7m59s
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
The test takes >5s on the QNAP act runner because dynamic import of
next/server has to transpile the module cold on first call. Raise the
per-test timeout to 15s to give it headroom without changing the test logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:06:56 +02:00
Hartmut 4a841d5acb feat(timeline): start at today and allow infinite scroll into the past
CI / Architecture Guardrails (pull_request) Successful in 17m31s
CI / Assistant Split Regression (pull_request) Successful in 9m42s
CI / Typecheck (pull_request) Successful in 20m48s
CI / Lint (pull_request) Successful in 8m6s
CI / Unit Tests (pull_request) Failing after 7m32s
CI / Build (pull_request) Successful in 9m12s
CI / E2E Tests (pull_request) Successful in 6m12s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m58s
CI / Release Images (pull_request) Has been skipped
Previously viewStart defaulted to today-30 and the scroll container had
no left-edge expansion logic, so users hit a hard wall when scrolling
left. This change:

- Sets viewStart default to today so the viewport opens with today at
  the left edge (URL ?startDate= override still respected).
- Adds left-edge auto-expansion in handleContainerScroll: when the user
  scrolls within 40 cells of the left boundary, 120 days are prepended
  and a useLayoutEffect applies the matching scrollLeft compensation in
  the same paint frame to prevent a visual jump.
- Floors backward navigation at 5 years (minDate) to prevent unbounded
  viewDays growth.
- Updates handleNavigateToday to match: resets to today rather than
  today-30.

Both resource view and project view use the same TimelineContext /
TimelineView, so both are fixed by this change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:16:34 +02:00
5 changed files with 149 additions and 13 deletions
+61 -1
View File
@@ -43,6 +43,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -77,6 +85,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -107,6 +123,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -132,6 +156,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -196,6 +228,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -251,6 +291,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -347,6 +395,14 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -493,6 +549,10 @@ jobs:
sleep 3 sleep 3
done done
- name: Pre-pull Docker base image
run: docker pull node:20-bookworm-slim
continue-on-error: true
- name: Build and start app (full profile) - name: Build and start app (full profile)
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile full up -d --build app run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile full up -d --build app
@@ -509,7 +569,7 @@ jobs:
# the act_runner job can reach). No DNS, no guessing. # the act_runner job can reach). No DNS, no guessing.
run: | run: |
set -e set -e
for i in $(seq 1 36); do for i in $(seq 1 60); do
CID=$(docker compose -f docker-compose.yml -f docker-compose.ci.yml ps -q app || true) CID=$(docker compose -f docker-compose.yml -f docker-compose.ci.yml ps -q app || true)
if [ -n "$CID" ]; then if [ -n "$CID" ]; then
APP_IP=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{if eq $k "gitea_gitea"}}{{$v.IPAddress}}{{end}}{{end}}' "$CID") APP_IP=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{if eq $k "gitea_gitea"}}{{$v.IPAddress}}{{end}}{{end}}' "$CID")
+1 -1
View File
@@ -24,6 +24,6 @@ export default defineConfig({
command: "node ./e2e/test-server.mjs", command: "node ./e2e/test-server.mjs",
url: e2eBaseUrl, url: e2eBaseUrl,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 180000, timeout: 300000,
}, },
}); });
@@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => {
const { GET } = await importRoute(); const { GET } = await importRoute();
const res = await GET(makeRequest()); const res = await GET(makeRequest());
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); }, 15_000); // next/server cold-import can take >5s on the act runner
it("proceeds when verifyCronSecret returns null (allowed)", async () => { it("proceeds when verifyCronSecret returns null (allowed)", async () => {
verifyCronSecretMock.mockReturnValue(null); verifyCronSecretMock.mockReturnValue(null);
@@ -284,7 +284,7 @@ export function TimelineProvider({
const d = new Date(sp); const d = new Date(sp);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return d;
} }
return addDays(today, -30); return addDays(today, -90);
}); });
const [viewDays, setViewDays] = useState(() => { const [viewDays, setViewDays] = useState(() => {
const sp = searchParams.get("days"); const sp = searchParams.get("days");
@@ -310,7 +310,7 @@ export function TimelineProvider({
const d = new Date(spStart); const d = new Date(spStart);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return d;
} }
return addDays(today, -30); return addDays(today, -90);
}); });
const spDays = searchParams.get("days"); const spDays = searchParams.get("days");
@@ -2,7 +2,7 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js"; import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js"; import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js"; import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
@@ -685,15 +685,70 @@ function TimelineViewContent({
const scrollRafRef = useRef<number | null>(null); const scrollRafRef = useRef<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0); const [scrollLeft, setScrollLeft] = useState(0);
// Pixels to add to scrollLeft after a left-extension re-render (prevents jump).
const pendingLeftCompensationPx = useRef(0);
// Flag: scroll viewport to today after the next viewStart-driven re-layout.
const pendingScrollToTodayRef = useRef(false);
// Guard reset on every real unmount (including Strict Mode fake-unmount) so the
// scroll-to-today fires correctly on remount.
const hasScrolledToTodayOnLoad = useRef(false);
useLayoutEffect(() => {
return () => {
hasScrolledToTodayOnLoad.current = false;
};
}, []);
// Scroll to today the first time the canvas is in the DOM (isInitialLoading → false).
// totalCanvasWidth is non-zero before data loads, so it can't be used as the trigger.
useLayoutEffect(() => {
if (isInitialLoading) return;
if (hasScrolledToTodayOnLoad.current) return;
const el = scrollContainerRef.current;
if (!el) return;
el.scrollLeft = toLeft(today);
hasScrolledToTodayOnLoad.current = true;
}, [isInitialLoading, toLeft, today]);
// Apply scroll compensation synchronously after the canvas grows (left-extend or Today button).
useLayoutEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const px = pendingLeftCompensationPx.current;
if (px !== 0) {
el.scrollLeft += px;
pendingLeftCompensationPx.current = 0;
} else if (pendingScrollToTodayRef.current) {
el.scrollLeft = toLeft(today);
pendingScrollToTodayRef.current = false;
}
}, [viewStart, toLeft, today]);
// 5-year floor — no practical data exists further back; prevents runaway growth.
const minDate = useMemo(() => addDays(today, -(365 * 5)), [today]);
// ─── Navigation callbacks for TimelineToolbar ──────────────────────────── // ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback( const handleNavigateBack = useCallback(
() => setViewStart((v) => addDays(v, -28)), () =>
[setViewStart], setViewStart((v) => {
); const candidate = addDays(v, -28);
const handleNavigateToday = useCallback( return candidate < minDate ? minDate : candidate;
() => setViewStart(addDays(today, -30)), }),
[setViewStart, today], [setViewStart, minDate],
); );
const handleNavigateToday = useCallback(() => {
const el = scrollContainerRef.current;
const todayMs = new Date(today).setHours(0, 0, 0, 0);
const vsMs = new Date(viewStart).setHours(0, 0, 0, 0);
const veMs = new Date(addDays(viewStart, viewDays)).setHours(0, 0, 0, 0);
if (todayMs >= vsMs && todayMs < veMs && el) {
// Today is in range — just scroll without touching state.
el.scrollLeft = toLeft(today);
} else {
// Today is out of range — reset the window and schedule a scroll.
pendingScrollToTodayRef.current = true;
setViewStart(addDays(today, -90));
}
}, [today, viewStart, viewDays, toLeft, setViewStart]);
const handleNavigateForward = useCallback( const handleNavigateForward = useCallback(
() => setViewStart((v) => addDays(v, 28)), () => setViewStart((v) => addDays(v, 28)),
[setViewStart], [setViewStart],
@@ -709,10 +764,31 @@ function TimelineViewContent({
const handleContainerScroll = useCallback(() => { const handleContainerScroll = useCallback(() => {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
if (!el) return; if (!el) return;
// Right-edge: extend future range
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth; const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
if (distanceFromRight < CELL_WIDTH * 40) { if (distanceFromRight < CELL_WIDTH * 40) {
setViewDays((d) => d + 120); setViewDays((d) => d + 120);
} }
// Left-edge: prepend past range and compensate scroll position so viewport doesn't jump
if (el.scrollLeft < CELL_WIDTH * 40 && viewStart > minDate) {
const daysToPrepend = 120;
// Count the exact visible days (respecting showWeekends) being prepended
let prependedVisible = 0;
for (let i = 1; i <= daysToPrepend; i++) {
const d = addDays(viewStart, -i);
const dow = d.getDay();
if (filters.showWeekends || (dow !== 0 && dow !== 6)) prependedVisible++;
}
pendingLeftCompensationPx.current = prependedVisible * CELL_WIDTH;
setViewStart((v) => {
const candidate = addDays(v, -daysToPrepend);
return candidate < minDate ? minDate : candidate;
});
setViewDays((d) => d + daysToPrepend);
}
scrollLeftRef.current = el.scrollLeft; scrollLeftRef.current = el.scrollLeft;
if (scrollRafRef.current === null) { if (scrollRafRef.current === null) {
scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = requestAnimationFrame(() => {
@@ -720,7 +796,7 @@ function TimelineViewContent({
setScrollLeft(scrollLeftRef.current); setScrollLeft(scrollLeftRef.current);
}); });
} }
}, [CELL_WIDTH, setViewDays]); }, [CELL_WIDTH, setViewDays, viewStart, minDate, setViewStart, filters.showWeekends]);
// ─── Canvas mousemove — only forwards event when drag overlay is active ─── // ─── Canvas mousemove — only forwards event when drag overlay is active ───
const handleMouseMove = useCallback( const handleMouseMove = useCallback(