Compare commits

...

5 Commits

Author SHA1 Message Date
cesnimda 86cdafb3ef fix(deploy): copy .npmrc before npm ci in frontend Dockerfile
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
Production deploy has been broken since the Next.js migration merged:
the Dockerfile ran `npm ci` right after COPY package*.json, before the
later `COPY . .` that would bring in .npmrc -- so the legacy-peer-deps
fix for react-scripts' stale TS ^4 peer constraint (added for CI in
dbb1580) never took effect in the actual deploy image, and every
deploy since has failed with the same ERESOLVE error CI hit before
that fix. Copy .npmrc alongside package*.json so npm ci sees it.
2026-07-12 02:15:49 +02:00
cesnimda 0e5845a95a Merge pull request 'feat(ui): circular match-score ring in job workspace' (#24) from feature/ui-rework-match-score-ring into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Failing after 45s
2026-07-12 02:05:18 +02:00
cesnimda ffb9888fb4 feat(ui): circular match-score ring in job workspace
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
Second UI-rework pass. The job workspace mockup's signature element is
a donut "coverage" ring for the deterministic CV match score; the app
had a linear progress bar instead. Replaced with a layered
CircularProgress ring (track + value arc, percentage centered) while
keeping every existing feature (band chip, matched/missing keyword
chips, section coverage) -- this is a pure visual upgrade to the
existing MatchScoreCard, not a feature reduction to match the mockup's
simpler single-panel layout.

Fixed match-score-panel.test.tsx's no-signal-state assertion, which
expected the removed inline "—" placeholder; restored it outside the
ring's conditional render.
2026-07-12 01:59:03 +02:00
cesnimda f4503f7b2c Merge pull request 'feat(ui): dark navy sidebar + restrained kanban status colors' (#23) from feature/ui-rework-sidebar-kanban into main
CI and Deploy / test (push) Successful in 2m2s
CI and Deploy / deploy (push) Failing after 39s
2026-07-12 01:53:27 +02:00
cesnimda 7cfbdf504a feat(ui): dark navy sidebar + restrained kanban status colors
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
First pass of the /frontend-design overhaul against the mockups at
F:\Pictures\website\jobtracker\new. Two highest-leverage gaps from the
backlog note ("dark sidebar, KPI cards, exact status colours"):

- AppShell: nav rail is now a fixed dark navy (#0f172a) regardless of
  the app's light/dark theme toggle, matching the mockup's signature
  look -- selected item gets an indigo-tinted pill + icon accent,
  muted slate text for the rest. Kept icon+label rows (mockup's sidebar
  is text-only) since the existing collapsed-sidebar mode depends on
  icons; that's a deliberate deviation, not an oversight.
- JobbjaktMark: replaced the briefcase glyph with the gradient
  checkmark-in-square mark used throughout the mockups (hero, dashboard,
  kanban) -- also fixed a latent SVG gradient id collision across
  multiple rendered instances via useId().
- KanbanBoard: mockup uses color sparingly (a small dot in the column
  header, a 4px accent on the card's left edge) rather than tinting the
  whole column/card background as the previous version did. Reworked
  to match; also swapped card title/subtitle order (job title bold,
  company/location as subtitle) per the mockup.

Remaining for follow-up passes: Dashboard KPI card layout and the job
workspace (candidate-fit ring, AI summary card) -- both structurally
close already but not yet pixel-matched.

Verified: `next build` clean, all 57 frontend tests green, dark
sidebar confirmed live (computed bg #0f172a) against a running dev
server with light content mode forced.
2026-07-12 01:45:20 +02:00
5 changed files with 94 additions and 83 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json ./
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
+9 -12
View File
@@ -1,21 +1,18 @@
import React from "react";
import React, { useId } from "react";
export default function JobbjaktMark(props: React.SVGProps<SVGSVGElement>) {
const gradientId = useId();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker" {...props}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34" role="img" aria-label="Jobbjakt" {...props}>
<defs>
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#14b8a6" />
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a" />
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" />
<path d="M14 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<path d="M36 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0" />
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<rect width="34" height="34" rx="9" fill={`url(#${gradientId})`} />
<path d="M9 17.5l5 5 11-12" fill="none" stroke="#ffffff" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -10,7 +10,6 @@ import {
DialogTitle,
FormControl,
InputLabel,
LinearProgress,
MenuItem,
Select,
Tab,
@@ -1229,27 +1228,35 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
</Box>
</Box>
) : (
<Typography variant="h4" sx={{ fontWeight: 800 }}></Typography>
)}
<Box sx={{ flex: 1, minWidth: 200 }}>
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
</Box>
</Box>
{score.hasEnoughSignal ? (
<LinearProgress
variant="determinate"
value={score.score}
color={color === "inherit" ? "primary" : color}
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
/>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
)}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
+22 -30
View File
@@ -4,7 +4,6 @@ import {
Box,
Card,
CardContent,
Chip,
IconButton,
Menu,
MenuItem,
@@ -115,24 +114,21 @@ export default function KanbanBoard() {
p: 1.5,
borderRadius: 3,
minHeight: 220,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
border: "1px solid",
borderColor: "divider",
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
{statusLabel(t, status)}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{statusLabel(t, status)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", fontWeight: 700 }}>
{list.length}
</Typography>
<Chip
size="small"
label={list.length}
sx={{
fontWeight: 800,
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
}}
/>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
@@ -144,21 +140,18 @@ export default function KanbanBoard() {
onDragEnd={() => setDragJobId(null)}
sx={{
cursor: "grab",
borderRadius: 3,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
backdropFilter: "blur(8px)",
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
borderRadius: 2.5,
borderLeft: `4px solid ${c}`,
boxShadow: theme.palette.mode === "dark" ? "none" : "0 1px 3px rgba(15,23,42,0.06)",
}}
>
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
{j.company?.name ?? ""}
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
{j.jobTitle}
</Typography>
<IconButton
size="small"
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
@@ -168,13 +161,12 @@ export default function KanbanBoard() {
<MoreHorizIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
{j.jobTitle}
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
{j.daysSince}d
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
</Box>
</CardContent>
</Card>
))}
+35 -20
View File
@@ -47,6 +47,16 @@ function initialsFrom(s?: string) {
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle --
// a deliberate signature element, not derived from theme tokens.
const SIDEBAR_BG = "#0f172a";
const SIDEBAR_BORDER = "rgba(255,255,255,0.08)";
const SIDEBAR_TEXT_MUTED = "#94a3b8";
const SIDEBAR_TEXT = "#e2e8f0";
const SIDEBAR_SELECTED_BG = "rgba(99,102,241,0.18)";
const SIDEBAR_SELECTED_TEXT = "#ffffff";
const SIDEBAR_SELECTED_ICON = "#a5b4fc";
export default function AppShell({
pageTitle,
breadcrumbs,
@@ -122,7 +132,7 @@ export default function AppShell({
{groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
{section && !desktopNavCollapsed ? (
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 600, textTransform: "uppercase" }}>
{section}
</Typography>
) : null}
@@ -135,20 +145,25 @@ export default function AppShell({
selected={selected}
onClick={() => onNavigate(item.to)}
title={desktopNavCollapsed ? item.label : undefined}
sx={(muiTheme: any) => ({
sx={{
borderRadius: 2,
mb: 0.5,
minHeight: 44,
px: desktopNavCollapsed ? 1 : 1.5,
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
border: "1px solid transparent",
color: SIDEBAR_TEXT_MUTED,
"&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
"&.Mui-selected": {
backgroundColor: muiTheme.vars.palette.action.hover,
borderColor: muiTheme.vars.palette.divider,
backgroundColor: SIDEBAR_SELECTED_BG,
color: SIDEBAR_SELECTED_TEXT,
},
})}
"&.Mui-selected:hover": {
backgroundColor: SIDEBAR_SELECTED_BG,
},
}}
>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
{item.badgeCount && item.badgeCount > 0 ? (
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
{item.icon}
@@ -166,16 +181,16 @@ export default function AppShell({
);
const drawerContent = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box sx={{ height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<JobbjaktMark style={{ width: 22, height: 22 }} />
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
{!desktopNavCollapsed ? (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: SIDEBAR_SELECTED_TEXT }}>
Jobbjakt
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
<Typography variant="caption" sx={{ color: SIDEBAR_TEXT_MUTED }}>
{t("appTagline")}
</Typography>
</Box>
@@ -183,13 +198,13 @@ export default function AppShell({
</Box>
</Box>
<Divider />
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
{renderNavList(grouped.top)}
<Box sx={{ flex: 1 }} />
<Divider />
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
{renderNavList(grouped.bottom)}
</Box>
@@ -406,19 +421,19 @@ export default function AppShell({
<Drawer
variant="permanent"
sx={(muiTheme: any) => ({
sx={{
display: { xs: "none", md: "block" },
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: "border-box",
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
borderRight: `1px solid ${SIDEBAR_BORDER}`,
backgroundColor: SIDEBAR_BG,
backgroundImage: "none",
boxShadow: "none",
},
})}
}}
open
>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
@@ -430,15 +445,15 @@ export default function AppShell({
open={drawerOpen}
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
sx={(muiTheme: any) => ({
sx={{
display: { xs: "block", md: "none" },
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
borderRight: `1px solid ${SIDEBAR_BORDER}`,
backgroundColor: SIDEBAR_BG,
backgroundImage: "none",
},
})}
}}
>
{drawerContent}
</Drawer>