Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 4 additions & 21 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: deploy

# On push to main: tests run on the VPS via a self-hosted runner; if
# they pass, the backend image is built on GitHub-hosted infra and
# they pass, the backend image is built and
# pushed to GHCR; the VPS-side runner then pulls the image and
# restarts the docker-compose stack in place.
#
Expand All @@ -19,25 +19,8 @@ env:
BACKEND_IMAGE: ${{ github.repository }}-backend

jobs:
# PR-only quick test on GitHub-hosted infra.
test-pr:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: vet
working-directory: backend
run: go vet ./...
- name: test
working-directory: backend
run: go test ./...

# Real gate for deploys: tests run on the VPS itself.
test-on-vps:
# run tests before deploy.
test:
if: github.event_name == 'push'
runs-on: [self-hosted, chessrepeat-vps]
steps:
Expand All @@ -55,7 +38,7 @@ jobs:

build-and-push:
if: github.event_name == 'push'
needs: test-on-vps
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
7 changes: 3 additions & 4 deletions backend/internal/ws/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,9 @@ type subscriber struct {
// burn CPU/DB by flooding the connection. Allocated per-connection
// so one chatty peer can't starve another.
readBudget *ratelimit.Bucket
// lastSeenAt is the unix-nano timestamp of the most recent inbound
// message (any type — pings, mutations, anything). The liveness
// watcher kicks the subscriber if this falls outside idleTimeout.
// atomic because reader (writes) and watcher (reads) run concurrently.
// updated after client sends an operation to server
// if currentTime - `lastSeenAt` < `livenessCheckInterval` after timer fires,
// kick subscriber
lastSeenAt atomic.Int64
}

Expand Down
152 changes: 79 additions & 73 deletions frontend/src/Chessrepeat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export const Chessrepeat = () => {
The current move we're training
*/

//TODO Fix logic here..
//TODO Fix logic here..
const targetDest = (): Key[] => {
// console.log("selectedNode fen", selectedNode?.data.fen)
const targetNode = useTrainerStore.getState().trainableContext.targetMove;
Expand Down Expand Up @@ -356,7 +356,8 @@ export const Chessrepeat = () => {
switch (guess(san)) {
case 'success': {
const secsUntilDue = train(true);
showBoxAtSquare(to, secsUntilDue);
// null when the move was deleted under us (train resynced instead)
if (secsUntilDue != null) showBoxAtSquare(to, secsUntilDue);
setNextTrainablePosition();
break;
}
Expand All @@ -375,7 +376,7 @@ export const Chessrepeat = () => {
}
};

const onAfterMove = (from: Key, to: Key, meta: MoveMetadata) => {
const onAfterMove = async (from: Key, to: Key, meta: MoveMetadata) => {
const fenBefore = selectedNode?.data.fen || initial;

// If a promo is already open, ignore additional moves (defensive)
Expand All @@ -390,7 +391,7 @@ export const Chessrepeat = () => {

// Normal move
const san = chessgroundToSan(fenBefore, from, to);
finishMove(san, meta, to);
await finishMove(san, meta, to);
updateDueCounts();
};

Expand Down Expand Up @@ -426,84 +427,89 @@ export const Chessrepeat = () => {
{/* BOARD */}
<div className="area-board" id="board-wrap">
<div className="board-card">
{chapter && chapter.enabledCount > 0 && (
<div className="group relative">
<div className="flex h-2 w-full overflow-hidden rounded-md bg-gray-200 cursor-default">
<div
className="h-full bg-brand-blue-light"
style={{ width: `${(chapter.unseenCount / chapter.enabledCount) * 100}%` }}
/>
<div
className="h-full bg-brand-blue"
style={{ width: `${(chapter.lastDueCount / chapter.enabledCount) * 100}%` }}
/>
</div>
{chapter && chapter.enabledCount > 0 && (
<div className="group relative">
<div className="flex h-2 w-full overflow-hidden rounded-md bg-gray-200 cursor-default">
<div
className="h-full bg-brand-blue-light"
style={{ width: `${(chapter.unseenCount / chapter.enabledCount) * 100}%` }}
/>
<div
className="h-full bg-brand-blue"
style={{ width: `${(chapter.lastDueCount / chapter.enabledCount) * 100}%` }}
/>
</div>

{/* Breakdown tooltip on hover */}
<div
className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 -translate-x-1/2
{/* Breakdown tooltip on hover */}
<div
className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 -translate-x-1/2
whitespace-nowrap rounded-md border border-gray-200 bg-white px-3 py-2 text-xs
text-gray-700 shadow-lg opacity-0 transition-opacity duration-150
group-hover:opacity-100"
>
<div className="flex items-center gap-2 py-0.5">
<GraduationCap size={14} className="text-sky-700" />
<span className="flex-1">To learn</span>
<span className="font-mono font-semibold">{chapter.unseenCount}</span>
</div>
<div className="flex items-center gap-2 py-0.5">
<History size={14} className="text-blue-800" />
<span className="flex-1">Due</span>
<span className="font-mono font-semibold">{chapter.lastDueCount}</span>
</div>
<div className="flex items-center gap-2 py-0.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-gray-200" />
<span className="flex-1">Learned</span>
<span className="font-mono font-semibold">
{chapter.enabledCount - chapter.unseenCount - chapter.lastDueCount}
</span>
</div>
<div className="mt-1 flex items-center gap-2 border-t border-gray-100 pt-1">
<span className="flex-1 font-semibold">Total</span>
<span className="font-mono font-semibold">{chapter.enabledCount}</span>
>
<table className="border-separate border-spacing-x-2 border-spacing-y-0.5">
<tbody>
<tr>
<td className="align-middle">
<GraduationCap size={14} className="text-sky-700" />
</td>
<td className="text-left">To Learn</td>
<td className="text-right font-mono font-semibold">
{chapter.unseenCount}
</td>
<td className="text-right font-mono text-gray-400">
{Math.round((chapter.unseenCount / chapter.enabledCount) * 100)}%
</td>
</tr>
<tr>
<td className="align-middle">
<History size={14} className="text-blue-800" />
</td>
<td className="text-left">Due Now</td>
<td className="text-right font-mono font-semibold">{chapter.lastDueCount}</td>
<td className="text-right font-mono text-gray-400">
{Math.round((chapter.lastDueCount / chapter.enabledCount) * 100)}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)}
<div ref={containerRef}>
<Chessground
orientation={chapter?.trainAs || 'white'}
fen={selectedNode?.data.fen || initial}
turnColor={turn}
lastMove={lastMove}
movable={{
free: false,
color: turn,
dests: calculateDests(),
events: { after: onAfterMove },
}}
drawable={{ autoShapes: createShapes() }}
/>
{pendingPromo && (
<PromotionOverlay
dest={pendingPromo.to}
color={promotionColorFromFen(pendingPromo.fenBefore)}
)}
<div ref={containerRef}>
<Chessground
orientation={chapter?.trainAs || 'white'}
onCancel={closePromo}
requiredRole={
trainingMethod === 'learn'
? promoRoleFromSan(useTrainerStore.getState().trainableContext?.targetMove?.data?.san)
: undefined
}
onPick={(role: PromoRole) => {
const { fenBefore, from, to, meta } = pendingPromo;
closePromo();
const san = chessgroundToSan(fenBefore, from, to, role);
finishMove(san, meta, to);
fen={selectedNode?.data.fen || initial}
turnColor={turn}
lastMove={lastMove}
movable={{
free: false,
color: turn,
dests: calculateDests(),
events: { after: onAfterMove },
}}
drawable={{ autoShapes: createShapes() }}
/>
)}
</div>
{pendingPromo && (
<PromotionOverlay
dest={pendingPromo.to}
color={promotionColorFromFen(pendingPromo.fenBefore)}
orientation={chapter?.trainAs || 'white'}
onCancel={closePromo}
requiredRole={
trainingMethod === 'learn'
? promoRoleFromSan(useTrainerStore.getState().trainableContext?.targetMove?.data?.san)
: undefined
}
onPick={(role: PromoRole) => {
const { fenBefore, from, to, meta } = pendingPromo;
closePromo();
const san = chessgroundToSan(fenBefore, from, to, role);
finishMove(san, meta, to);
}}
/>
)}
</div>
</div>

{/* CONTROLS — part of the same board panel, always directly
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/Debug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ export const Debug: React.FC = () => {
const selectedPath = useTrainerStore.getState().selectedPath;
const TrainingMethod = useTrainerStore.getState().trainingMethod;

const { repertoire, selectedChapterId } = useTrainerStore.getState();

const chapter = repertoire.find((c) => c.uuid === selectedChapterId);
if (!chapter) return undefined;
console.log(targetMove?.data?.fen);
return (
<div className="absolute bottom-0 p-15">
<div>{`chapter ${chapter.unseenCount}`}</div>
<div>{`TrainingPath ${trainingPath}`}</div>
<div>{`selectedPath ${selectedPath}`}</div>
<div>{`TrainingMethod ${TrainingMethod}`}</div>
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ export function Header({ connectedUsers, incomingCollaboratorsCount = 0, onOpenC
</>
) : (
showSignIn && (
<button type="button" onClick={openLogin} className="header-link" title="Sign in">
<button
type="button"
onClick={openLogin}
className="header-link header-signin"
title="Sign in"
>
<span>sign in</span>
<LogIn />
</button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/MobileCommentPopout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const MobileCommentPopout: React.FC = () => {
<dialog
open
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50
border-none bg-white rounded-lg shadow-lg w-full max-w-md p-4"
border-none bg-white rounded-lg shadow-lg w-[calc(100%-2rem)] max-w-md p-4"
>
<button
className="absolute -top-3 -right-3 bg-red-500 text-white rounded-full h-8 w-8
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/modals/AddToRepertoireModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ const AddToRepertoireModal: React.FC = () => {
<dialog
open
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[1000]
border-none bg-white rounded-lg shadow-lg w-full max-w-lg"
border-none bg-white rounded-lg shadow-lg w-[calc(100%-2rem)] max-w-lg"
>
{/* Close button */}
<button
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/modals/SettingsModal.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
left: 50%;
transform: translate(-50%, -50%);
z-index: 50;
width: 100%;
/* leave a margin on each side on narrow viewports so the close button,
which hangs off the right edge (right: -0.75rem), isn't clipped. */
width: calc(100% - 2rem);
max-width: 28rem;
border: none;
background: #fff;
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@
}

#header .header-link svg {
width: 1.25rem;
height: 1.25rem;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
}

Expand All @@ -142,9 +142,14 @@
display: none;
}

/* the sign-in label always stays visible, even at the narrowest width. */
#header .header-signin span {
display: inline;
}

#header .header-link svg {
width: 0.78em;
height: 0.78em;
width: 0.95em;
height: 0.95em;
}
}

Expand Down
1 change: 1 addition & 0 deletions frontend/src/services/repertoire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type FetchRepertoireResult = {
// server response carries the session's user, so we hydrate auth and
// author from it before installing the chapters.
export async function fetchRepertoire(): Promise<FetchRepertoireResult> {
console.log("fetch repetoire")
const store = useTrainerStore.getState();
const { repertoireAuthor } = store;
const url = repertoireAuthor
Expand Down
Loading