Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55b861cb43 | |||
| 184efee0e1 | |||
| 5db6e24ee0 | |||
| eb1875e524 | |||
| 12044f638e | |||
| 6bc23b3407 | |||
| 2383bcbdc0 | |||
| 0e9d6ec388 | |||
| 7285668c52 | |||
| 944d36bdb2 | |||
| 6ec512e302 | |||
| 4a841d5acb |
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user