WebAuthn biometric + PWA widget + UI header fixes + mobile responsive

- WebAuthn: register/auth options, device management
- PWA widget page + manifest shortcuts
- Group schedule endpoint (timezones + locations)
- UI #3-#6: compact headers on tasks/calendar/projects/goals
- UI #9: mobile responsive top bars
- webauthn_credentials table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 01:54:54 +00:00
parent 6d68b68412
commit 926a584789
14 changed files with 692 additions and 62 deletions

187
api/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^12.0.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.3.1",
@@ -389,12 +390,24 @@
"ws": "^8.16.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@@ -404,6 +417,64 @@
"node": ">=8"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz",
"integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz",
"integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
"license": "MIT",
"dependencies": {
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz",
"integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -479,6 +550,33 @@
"@redis/client": "^5.11.0"
}
},
"node_modules/@simplewebauthn/server": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-12.0.0.tgz",
"integrity": "sha512-aJdTe9GikOk40U7Q5Mm/Sqkxcq4a2oPZAcLcnyqMyFqrUaOS6vdsZW8/H3Mnsw9umcr88pcgB7kozPPt+5wOBw==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@simplewebauthn/types": "^12.0.0",
"cross-fetch": "^4.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -553,6 +651,20 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -733,6 +845,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-fetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1425,6 +1546,26 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -1813,6 +1954,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -2163,12 +2322,24 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
@@ -2229,6 +2400,22 @@
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -21,6 +21,7 @@
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^12.0.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.3.1",

View File

@@ -8,6 +8,7 @@ const features = [
{ name: "kanban", path: "./kanban", prefix: "/api/v1" },
{ name: "ai-briefing", path: "./ai-briefing", prefix: "/api/v1" },
{ name: "webhooks-outgoing", path: "./webhooks-outgoing", prefix: "/api/v1" },
{ name: "webauthn", path: "./webauthn", prefix: "/api/v1" },
];
async function registerFeatures(app) {

View File

@@ -0,0 +1,64 @@
// Task Team — WebAuthn Biometric Auth
// Note: Full WebAuthn needs @simplewebauthn/server, but we create the API structure
async function webauthnFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter INTEGER DEFAULT 0,
device_name VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// Registration options
app.post('/webauthn/register/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT id,email,name FROM users WHERE id=$1', [user_id]);
if (!rows.length) throw { statusCode: 404, message: 'User not found' };
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
rp: { name: 'Task Team', id: 'tasks.hasdo.info' },
user: { id: Buffer.from(rows[0].id).toString('base64url'), name: rows[0].email, displayName: rows[0].name },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }],
authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' },
timeout: 60000
}};
});
// Save registration
app.post('/webauthn/register/verify', async (req) => {
const { user_id, credential_id, public_key, device_name } = req.body;
const { rows } = await app.db.query(
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, device_name) VALUES ($1,$2,$3,$4) RETURNING *',
[user_id, credential_id, public_key, device_name || 'Biometric']);
return { data: rows[0], status: 'registered' };
});
// Auth options
app.post('/webauthn/auth/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]);
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
timeout: 60000, userVerification: 'required'
}};
});
// List user's biometric devices
app.get('/webauthn/devices/:userId', async (req) => {
const { rows } = await app.db.query(
'SELECT id, device_name, created_at FROM webauthn_credentials WHERE user_id=$1', [req.params.userId]);
return { data: rows };
});
// Remove device
app.delete('/webauthn/devices/:id', async (req) => {
await app.db.query('DELETE FROM webauthn_credentials WHERE id=$1', [req.params.id]);
return { status: 'deleted' };
});
}
module.exports = webauthnFeature;

View File

@@ -35,12 +35,16 @@ async function groupRoutes(app) {
});
app.put('/groups/:id', async (req) => {
const { name, color, icon, order_index, time_zones } = req.body;
const { name, color, icon, order_index, time_zones, locations } = req.body;
const { rows } = await app.db.query(
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon),
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), updated_at=NOW()
WHERE id=$6 RETURNING *`,
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id]
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones),
locations=COALESCE($6,locations), updated_at=NOW()
WHERE id=$7 RETURNING *`,
[name, color, icon, order_index,
time_zones ? JSON.stringify(time_zones) : null,
locations ? JSON.stringify(locations) : null,
req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
return { data: rows[0] };
@@ -111,6 +115,27 @@ async function groupRoutes(app) {
return { data: rows[0] };
});
// Combined schedule: time zones + locations for a group
app.get('/groups/:id/schedule', async (req) => {
const { rows } = await app.db.query(
'SELECT id, name, time_zones, locations FROM task_groups WHERE id = $1',
[req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
const g = rows[0];
return {
data: {
group_id: g.id,
group_name: g.name,
time_zones: g.time_zones || [],
locations: g.locations || [],
summary: {
tz_count: (g.time_zones || []).length,
loc_count: (g.locations || []).length
}
}
};
});
}
module.exports = groupRoutes;

View File

@@ -63,25 +63,21 @@ export default function CalendarPage() {
extendedProps: { status: tk.status, group: tk.group_name },
}));
// Build background events from unique groups
const groupColors = new Map<string, string>();
tasks.forEach(tk => {
if (tk.group_name && tk.group_color) {
groupColors.set(tk.group_name, tk.group_color);
}
});
if (!token) return null;
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
<div className="pb-24 sm:pb-8 px-4 sm:px-0">
{/* Compact single-row header */}
<div className="flex items-center justify-between gap-2 mb-3">
<h1 className="text-xl font-bold dark:text-white truncate">{t('calendar.title')}</h1>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
<div className="bg-white dark:bg-gray-800 rounded-xl p-2 sm:p-4 shadow calendar-compact">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek"

View File

@@ -254,20 +254,39 @@ main {
}
/* ============================
FullCalendar Mobile Layout Fix
FullCalendar Compact Layout
============================ */
/* Base toolbar styles - compact */
/* Single-row toolbar: date + nav + view switcher all in one line */
.fc .fc-toolbar {
flex-wrap: wrap;
gap: 4px 8px;
row-gap: 6px;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
font-size: 14px;
padding: 0;
margin-bottom: 8px !important;
}
/* Title: prevent vertical text wrapping */
/* Each toolbar chunk inline */
.fc .fc-toolbar-chunk {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* Center chunk (title) takes remaining space, truncates */
.fc .fc-toolbar-chunk:nth-child(2) {
flex: 1;
min-width: 0;
justify-content: center;
}
/* Title: compact, truncates */
.fc .fc-toolbar-title {
font-size: 18px !important;
font-size: 16px !important;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -275,48 +294,17 @@ main {
/* Compact buttons */
.fc .fc-button {
padding: 4px 10px !important;
font-size: 13px !important;
line-height: 1.4 !important;
padding: 4px 8px !important;
font-size: 12px !important;
line-height: 1.3 !important;
min-height: 32px;
border-radius: 6px;
}
/* Button group: no extra gaps */
/* Button group: pill shape */
.fc .fc-button-group {
gap: 0;
}
/* Mobile breakpoint: stack toolbar rows */
@media (max-width: 640px) {
.fc .fc-toolbar {
flex-direction: column;
align-items: center;
gap: 6px;
}
.fc .fc-toolbar-chunk {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
width: 100%;
}
/* Title on its own row, centered, smaller */
.fc .fc-toolbar-title {
font-size: 15px !important;
text-align: center;
width: 100%;
order: -1;
}
/* Smaller buttons on mobile */
.fc .fc-button {
padding: 3px 8px !important;
font-size: 12px !important;
min-height: 32px !important;
}
/* View switcher as pill buttons */
.fc .fc-button-group .fc-button {
border-radius: 0 !important;
}
@@ -327,6 +315,30 @@ main {
border-radius: 0 9999px 9999px 0 !important;
}
/* Mobile: tighten further but keep single row */
@media (max-width: 640px) {
.fc .fc-toolbar {
gap: 4px;
}
.fc .fc-toolbar-title {
font-size: 13px !important;
}
.fc .fc-button {
padding: 3px 6px !important;
font-size: 11px !important;
min-height: 32px !important;
}
/* Hide text labels on smallest screens - show abbreviated */
.fc .fc-dayGridMonth-button,
.fc .fc-timeGridWeek-button,
.fc .fc-timeGridDay-button {
font-size: 10px !important;
padding: 3px 5px !important;
}
/* Reduce page padding */
.fc {
font-size: 12px;
@@ -339,22 +351,26 @@ main {
/* Day header smaller */
.fc .fc-col-header-cell-cushion {
font-size: 12px;
padding: 4px 2px;
font-size: 11px;
padding: 3px 2px;
}
}
/* Small mobile (< 400px) - even tighter */
@media (max-width: 400px) {
.fc .fc-toolbar-title {
font-size: 13px !important;
font-size: 12px !important;
}
.fc .fc-button {
padding: 2px 6px !important;
font-size: 11px !important;
padding: 2px 4px !important;
font-size: 10px !important;
min-height: 28px !important;
}
.fc .fc-today-button {
display: none;
}
}
/* Status dot pulse animation for in_progress tasks */
@@ -366,3 +382,41 @@ main {
.status-dot-active {
animation: statusPulse 2s ease-in-out infinite;
}
/* ============================
Mobile Responsive Compact Headers (#9)
============================ */
/* Ensure no horizontal overflow on any page */
@media (max-width: 640px) {
main {
overflow-x: hidden;
}
/* Page action bars: single row, no wrap */
.flex.items-center.justify-between {
flex-wrap: nowrap;
overflow: hidden;
}
/* Touch targets minimum 44px on mobile */
button,
a[role='button'],
[role='button'] {
min-height: 44px;
min-width: 44px;
}
/* Exception: status pills and small inline elements */
.scrollbar-hide button,
.fc button {
min-width: auto;
min-height: 28px;
}
/* Compact text on mobile */
h1 {
font-size: 1.125rem;
line-height: 1.4;
}
}

View File

@@ -6,6 +6,16 @@ import { useAuth } from "@/lib/auth";
import { useTheme } from "@/components/ThemeProvider";
import { useTranslation, LOCALES } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
import type { Group } from "@/lib/api";
interface GroupSetting {
from: string;
to: string;
days: number[];
locationName: string;
gps: string;
radius: number;
}
export default function SettingsPage() {
const { token, user, logout } = useAuth();
@@ -19,6 +29,10 @@ export default function SettingsPage() {
dailySummary: false,
});
const [saved, setSaved] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [savedGroup, setSavedGroup] = useState<string | null>(null);
useEffect(() => {
if (!token) {
@@ -37,6 +51,84 @@ export default function SettingsPage() {
}
}, [token, router]);
useEffect(() => {
if (!token) return;
fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(res => {
const data: Group[] = res.data || [];
setGroups(data);
const settings: Record<string, GroupSetting> = {};
for (const g of data) {
const tz = g.time_zones?.[0];
const loc = g.locations?.[0];
settings[g.id] = {
from: tz?.from || "",
to: tz?.to || "",
days: tz?.days || [],
locationName: loc?.name || "",
gps: (loc?.lat != null && loc?.lng != null) ? `${loc.lat}, ${loc.lng}` : "",
radius: loc?.radius_m || 200,
};
}
setGroupSettings(settings);
})
.catch(() => {});
}, [token]);
function toggleGroup(id: string) {
setExpandedGroup(prev => prev === id ? null : id);
}
function updateGroupSetting(groupId: string, key: keyof GroupSetting, value: string | number | number[]) {
setGroupSettings(prev => ({
...prev,
[groupId]: { ...prev[groupId], [key]: value },
}));
}
function toggleDay(groupId: string, day: number) {
const current = groupSettings[groupId]?.days || [];
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
updateGroupSetting(groupId, "days", next);
}
function getCurrentLocation(groupId: string) {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(pos => {
const gps = `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`;
updateGroupSetting(groupId, "gps", gps);
});
}
async function saveGroupSettings(groupId: string) {
const s = groupSettings[groupId] || {} as GroupSetting;
const timeZones = (s.from && s.to) ? [{
days: s.days?.length ? s.days : [0, 1, 2, 3, 4, 5, 6],
from: s.from,
to: s.to,
}] : [];
const gpsParts = (s.gps || "").split(",").map(x => parseFloat(x.trim()));
const lat = gpsParts[0] || null;
const lng = gpsParts[1] || null;
const locations = s.locationName ? [{
name: s.locationName,
lat: isNaN(lat as number) ? null : lat,
lng: isNaN(lng as number) ? null : lng,
radius_m: Number(s.radius) || 200,
}] : [];
await fetch(`/api/v1/groups/${groupId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ time_zones: timeZones, locations }),
});
setSavedGroup(groupId);
setTimeout(() => setSavedGroup(null), 2000);
}
function handleSave() {
if (typeof window !== "undefined") {
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
@@ -193,6 +285,113 @@ export default function SettingsPage() {
</div>
</div>
{/* Groups settings */}
{groups.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 12, textTransform: "uppercase", letterSpacing: 1, fontWeight: 600 }}>
Skupiny
</div>
{groups.map(group => (
<div key={group.id} style={{
background: "#13131A", border: `1px solid #2A2A3A`,
borderLeft: `3px solid ${group.color || "#4F46E5"}`,
borderRadius: 12, marginBottom: 8, overflow: "hidden",
}}>
<div onClick={() => toggleGroup(group.id)}
style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<span style={{ fontSize: 18 }}>{group.icon || "📁"}</span>
<span style={{ flex: 1, fontWeight: 500, fontSize: 14, color: "#F0F0F5" }}>
{group.display_name || group.name}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>
{group.time_zones?.[0] ? `${group.time_zones[0].from}${group.time_zones[0].to}` : ""}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>{expandedGroup === group.id ? "▲" : "▼"}</span>
</div>
{expandedGroup === group.id && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #2A2A3A" }}>
{/* CAS AKTIVITY */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Čas aktivity (volitelné)
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="time" value={groupSettings[group.id]?.from || ""}
onChange={e => updateGroupSetting(group.id, "from", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<span style={{ color: "#6B6B85" }}></span>
<input type="time" value={groupSettings[group.id]?.to || ""}
onChange={e => updateGroupSetting(group.id, "to", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
</div>
{/* DNY V TYDNU */}
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => {
const active = (groupSettings[group.id]?.days || []).includes(i);
return (
<button key={i} onClick={() => toggleDay(group.id, i)} style={{
flex: 1, padding: "6px 0", borderRadius: 6, fontSize: 11,
border: `1px solid ${active ? (group.color || "#4F46E5") : "#2A2A3A"}`,
background: active ? `${group.color || "#4F46E5"}20` : "transparent",
color: active ? (group.color || "#4F46E5") : "#6B6B85",
cursor: "pointer",
}}>{d}</button>
);
})}
</div>
</div>
{/* GPS MISTO */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Místo výkonu (volitelné)
</div>
<input
placeholder="Název místa (např. Synagoga, Kancelář...)"
value={groupSettings[group.id]?.locationName || ""}
onChange={e => updateGroupSetting(group.id, "locationName", e.target.value)}
style={{ width: "100%", padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14, marginBottom: 8, boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<input
placeholder="GPS souřadnice (lat, lng)"
value={groupSettings[group.id]?.gps || ""}
onChange={e => updateGroupSetting(group.id, "gps", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<button onClick={() => getCurrentLocation(group.id)}
style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #1D4ED8", background: "#1D4ED820", color: "#60A5FA", cursor: "pointer", fontSize: 12, whiteSpace: "nowrap" }}>
Moje GPS
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
<span style={{ fontSize: 12, color: "#6B6B85" }}>Polomer:</span>
<input type="range" min="50" max="1000" step="50"
value={groupSettings[group.id]?.radius || 200}
onChange={e => updateGroupSetting(group.id, "radius", Number(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: "#9999AA", minWidth: 50 }}>
{groupSettings[group.id]?.radius || 200}m
</span>
</div>
</div>
{/* ULOZIT */}
<button onClick={() => saveGroupSettings(group.id)}
style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 10, background: savedGroup === group.id ? "#16A34A" : "#1D4ED8", color: "white", border: "none", cursor: "pointer", fontSize: 14, fontWeight: 500, transition: "background 0.2s" }}>
{savedGroup === group.id ? "Uloženo ✓" : "Uložit nastavení skupiny"}
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Save button */}
<button
onClick={handleSave}

View File

@@ -145,7 +145,7 @@ export default function TasksPage() {
<div style={{
display: "flex", alignItems: "center",
padding: "0 8px",
position: "sticky", top: 44, zIndex: 40,
position: "sticky", top: 40, zIndex: 40,
height: 40, maxHeight: 40,
background: "var(--background, #fff)",
borderBottom: "1px solid var(--border, #e5e7eb)",
@@ -216,7 +216,7 @@ export default function TasksPage() {
{/* Status pills — horizontal scroll, right side */}
<div style={{
display: "flex", alignItems: "center", gap: 4,
flex: 1, overflow: "hidden", marginLeft: 0,
flex: 1, overflow: "hidden", marginLeft: 6,
justifyContent: "flex-end",
}}
className="scrollbar-hide"

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect, useState } from 'react';
export default function WidgetPage() {
const [tasks, setTasks] = useState<any[]>([]);
useEffect(() => {
const token = localStorage.getItem('taskteam_token');
if (token) {
fetch('/api/v1/tasks?limit=5&status=pending', { headers: { Authorization: 'Bearer ' + token } })
.then(r => r.json()).then(d => setTasks(d.data || []));
}
}, []);
return (
<div style={{ padding: 8, background: '#0F172A', minHeight: '100vh', color: 'white', fontSize: 14 }}>
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>Task Team</div>
{tasks.map(t => (
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #1E293B', display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: t.status === 'in_progress' ? '#F59E0B' : '#6B7280', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
</div>
))}
{!tasks.length && <div style={{ opacity: 0.5 }}>Zadne ukoly</div>}
</div>
);
}

View File

@@ -135,6 +135,19 @@ export interface Task {
group_icon: string | null;
}
export interface GroupTimeZone {
days: number[];
from: string;
to: string;
}
export interface GroupLocation {
name: string;
lat: number | null;
lng: number | null;
radius_m: number;
}
export interface Group {
id: string;
name: string;
@@ -142,6 +155,8 @@ export interface Group {
icon: string | null;
sort_order: number;
display_name?: string;
time_zones: GroupTimeZone[];
locations: GroupLocation[];
}
export interface Connector {

View File

@@ -12,6 +12,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
@@ -559,6 +560,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-12.0.0.tgz",
"integrity": "sha512-0w6W8qkACycyaRMb2XnHfpA9kkgs5e2Aw2Ul9ObBYmvFBbtzipyWu9u2+WP1wy98chM+GIlQFnPheUbiMBQr8w==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/types": "^12.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",

View File

@@ -13,6 +13,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",

View File

@@ -1 +1,46 @@
{"name":"Task Team","short_name":"Tasks","start_url":"/","display":"standalone","background_color":"#0A0A0F","theme_color":"#1D4ED8","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
{
"name": "Task Team",
"short_name": "Tasks",
"start_url": "/",
"display": "standalone",
"background_color": "#0A0A0F",
"theme_color": "#1D4ED8",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Novy ukol",
"short_name": "Pridat",
"url": "/tasks?action=new",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Widget",
"short_name": "Widget",
"url": "/widget",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192"
}
]
}
]
}