From 957612453e770dc3c3d4eaf9c73c11f19bff0f8b Mon Sep 17 00:00:00 2001 From: Jawaher Laham Date: Wed, 14 Oct 2026 00:43:48 +0200 Subject: [PATCH 01/13] Legg til turstatistikk og planlagte turer i profilen --- server/index.mjs | 51 +++++++++++++++++++++++++++++-- src/Profile.css | 38 +++++++++++++++++++++++ src/Profile.tsx | 79 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 146 insertions(+), 22 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index a15d232..0447450 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -37,6 +37,15 @@ const appSchemaReady = pool.query(` CREATE UNIQUE INDEX IF NOT EXISTS favorites_user_route_id_idx ON norwegian_explorer_app.favorites (user_id, route_id) WHERE route_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_idx ON norwegian_explorer_app.users (lower(username)); + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.user_routes ( + user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, + route_id text NOT NULL, + route_name text NOT NULL, + route_number text NOT NULL, + status text NOT NULL CHECK (status IN ('PLANNED','COMPLETED')), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id,route_id) + ); `) const routeCatalogReady = appSchemaReady.then(async () => { const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") @@ -75,7 +84,22 @@ async function profileFor(user) { FROM norwegian_explorer_app.favorites f LEFT JOIN norwegian_explorer_app.route_catalog c ON c.id=f.route_id WHERE f.user_id=$1 ORDER BY f.created_at DESC`, [user.id]) - return { username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, favorites: rows } + const { rows: trips } = await pool.query(` + WITH routes AS ( + SELECT id,length_meters FROM norwegian_explorer_app.route_catalog + UNION ALL SELECT id,length_meters FROM norwegian_explorer_app.route_loops + ) + SELECT t.route_id AS id,t.route_name AS name,t.route_number AS number,t.status, + t.updated_at::text AS "updatedAt",r.length_meters AS "lengthMeters" + FROM norwegian_explorer_app.user_routes t LEFT JOIN routes r ON r.id=t.route_id + WHERE t.user_id=$1 ORDER BY t.updated_at DESC`, [user.id]) + const { rows: counties } = await pool.query(`SELECT count(DISTINCT a.county_number)::int AS count + FROM norwegian_explorer_app.user_routes t JOIN norwegian_explorer_app.route_areas a ON a.route_id=t.route_id + WHERE t.user_id=$1 AND t.status='COMPLETED'`, [user.id]) + const completed = trips.filter(trip => trip.status === 'COMPLETED') + return { username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, favorites: rows, + trips, stats: { completedCount: completed.length, plannedCount: trips.length-completed.length, + totalLengthMeters: completed.reduce((sum,trip) => sum+(trip.lengthMeters || 0),0), countyCount: counties[0].count } } } async function requireUser(token) { const user = await userFromToken(token) @@ -108,7 +132,10 @@ const schema = createSchema({ type AreaOptions { counties: [AreaOption!]!, municipalities: [AreaOption!]! } type OsmPage { geojson: String!, truncated: Boolean! } type Favorite { id: ID!, name: String!, number: String!, createdAt: String! } - type UserProfile { username: String!, email: String, phone: String, avatar: String, favorites: [Favorite!]! } + enum TripStatus { PLANNED COMPLETED } + type UserTrip { id: ID!, name: String!, number: String!, status: TripStatus!, updatedAt: String!, lengthMeters: Float } + type TripStats { completedCount: Int!, plannedCount: Int!, totalLengthMeters: Float!, countyCount: Int! } + type UserProfile { username: String!, email: String, phone: String, avatar: String, favorites: [Favorite!]!, trips: [UserTrip!]!, stats: TripStats! } type AuthPayload { token: String!, user: UserProfile! } type Query { osmNearby(west: Float!, south: Float!, east: Float!, north: Float!): OsmPage! @@ -125,6 +152,7 @@ const schema = createSchema({ logout(token: String!): Boolean! toggleFavorite(token: String!, routeId: ID!): UserProfile! updateProfile(token: String!, email: String, phone: String, avatar: String): UserProfile! + setTripStatus(token: String!, routeId: ID!, status: TripStatus): UserProfile! } `, mountainTypeDefs], resolvers: [{ Query: { @@ -251,7 +279,8 @@ const schema = createSchema({ VALUES ($1,$2,$3) RETURNING id,username`, [cleanUsername, hash, salt]) const token = await createSession(client, rows[0].id) await client.query('COMMIT') - return { token, user: { username: rows[0].username, favorites: [] } } + return { token, user: { username: rows[0].username, favorites: [], trips: [], + stats: { completedCount: 0, plannedCount: 0, totalLengthMeters: 0, countyCount: 0 } } } } catch (error) { await client.query('ROLLBACK') if (error?.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.') @@ -283,6 +312,22 @@ const schema = createSchema({ if (removed.rowCount === 0) await pool.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES ($1,$2,$3,$4)', [user.id,routeId,routeResult.rows[0].name,routeResult.rows[0].number]) return profileFor(user) }, + setTripStatus: async (_, { token, routeId, status }) => { + const user = await requireUser(token) + if (!/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + if (status == null) { + await pool.query('DELETE FROM norwegian_explorer_app.user_routes WHERE user_id=$1 AND route_id=$2', [user.id,routeId]) + } else { + await routeCatalogReady + const { rows } = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) + if (!rows[0]) throw new GraphQLError('Ruten finnes ikke.') + await pool.query(`INSERT INTO norwegian_explorer_app.user_routes(user_id,route_id,route_name,route_number,status) + VALUES ($1,$2,$3,$4,$5) ON CONFLICT (user_id,route_id) DO UPDATE SET status=excluded.status,updated_at=now()`, + [user.id,routeId,rows[0].name,rows[0].number,status]) + } + return profileFor(user) + }, updateProfile: async (_, { token, email, phone, avatar }) => { const user = await requireUser(token) const cleanEmail = email?.trim() || null diff --git a/src/Profile.css b/src/Profile.css index e78bde8..a656f6b 100644 --- a/src/Profile.css +++ b/src/Profile.css @@ -29,4 +29,42 @@ .auth-card .profile-form button { margin-top: 4px; } .auth-card > .text-button { margin-top: 18px; } .auth-error { color: #9b2c20; } +.badge-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.badge-heading > span { font-size: 12px; color: #647267; } +.badge-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; list-style: none; padding: 0; } +.achievement { display: flex; gap: 12px; align-items: start; padding: 16px; border: 1px solid #dce1d7; border-radius: 12px; background: #f5f6f0; } +.achievement.earned { background: #e7f1e9; border-color: #126b51; } +.achievement-symbol { display: grid; place-items: center; flex: 0 0 42px; height: 42px; border-radius: 50%; background: #e0e5dc; font-size: 20px; font-weight: 700; } +.earned .achievement-symbol { background: #126b51; color: white; } +.achievement strong { font-size: 14px; } +.achievement-state { display: block; font-size: 11px; font-weight: 600; margin-top: 4px; } +.achievement p { font-size: 12px; color: #647267; margin-bottom: 0; } +@media(max-width:900px){.badge-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:600px){.badge-grid{grid-template-columns:1fr}} +.monthly-goal { background: #f5f6f0; border: 1px solid #dce1d7; border-radius: 14px; padding: 20px; margin: 20px 0; } +.monthly-goal h3 { margin: 0 0 14px; } +.monthly-goal form { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; } +.monthly-goal label { display: grid; gap: 6px; font-size: 13px; } +.monthly-goal input { font: inherit; border: 1px solid #9cac9e; border-radius: 8px; padding: 10px; min-width: 0; } +.goal-progress progress { width: 100%; height: 16px; accent-color: #126b51; } +.trip-diary { border-top: 1px solid #dce1d7; padding-top: 14px; margin-top: 14px; } +.trip-diary summary { cursor: pointer; font-weight: 600; } +.trip-diary form { display: grid; gap: 12px; margin-top: 14px; } +.trip-diary label { display: grid; gap: 6px; font-size: 13px; } +.trip-diary input,.trip-diary select,.trip-diary textarea { width: 100%; min-width: 0; box-sizing: border-box; font: inherit; padding: 10px; border: 1px solid #9cac9e; border-radius: 8px; background: white; } +.trip-diary textarea { resize: vertical; } +.profile-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin-top: 22px; } +.profile-stats > div { padding: 20px; border-radius: 12px; background: #edf2e9; } +.profile-stats strong { display: block; font-size: 28px; color: #126b51; } +.profile-stats span,.stats-help { color: #647267; font-size: 12px; } +.profile-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0; border-bottom: 1px solid #dce1d7; padding-bottom: 14px; } +.profile-tabs button { background: #f5f6f0; color: #203b32; border: 2px solid transparent; } +.profile-tabs button[aria-pressed=true],.selected-trip-actions button[aria-pressed=true] { background: #e7f1e9; color: #126b51; border: 2px solid #126b51; } +.selected-trip-actions button { margin: 0 8px 8px 0; border: 2px solid transparent; } +.profile-empty { background: #f5f6f0; border-radius: 12px; padding: 26px; } +.profile-empty p { color: #647267; } +.favorite-list li { background: #edf2e9; border: 1px solid #dce1d7; border-radius: 12px; padding: 12px; } +.favorite-list .trip-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.favorite-list .trip-actions button { width: auto; font-size: 12px; background: white; border: 1px solid #9cac9e; padding: 8px; } +@media(max-width:600px){.profile-stats{grid-template-columns:1fr}.profile-heading{flex-wrap:wrap}.profile-tabs button{flex:1 1 40%}} @media (max-width: 600px) { .auth-main { min-height: calc(100vh - 72px); padding: 24px 16px; } .auth-card { padding: 28px 22px; } } diff --git a/src/Profile.tsx b/src/Profile.tsx index 9af2b31..213d129 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -2,11 +2,14 @@ import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react' import './Profile.css' type Favorite = { id: string; name: string; number: string; createdAt: string } -type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; favorites: Favorite[] } +type TripStatus = 'PLANNED' | 'COMPLETED' +type UserTrip = Favorite & { status: TripStatus; updatedAt: string; lengthMeters: number | null } +const PROFILE_FIELDS = 'username email phone avatar favorites{id name number createdAt} trips{id name number status updatedAt lengthMeters} stats{completedCount plannedCount totalLengthMeters countyCount}' +type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; favorites: Favorite[]; trips: UserTrip[]; stats: { completedCount: number; plannedCount: number; totalLengthMeters: number; countyCount: number } } type SelectedRoute = { id?: string; name: string; number: string } const TOKEN_KEY = 'norwegian-explorer-session' -async function graphQL(query: string, variables: Record, signal = AbortSignal.timeout(15000)) { +async function graphQL(query: string, variables: Record, signal = AbortSignal.timeout(15000)) { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify({ query, variables }) }) const result = await response.json().catch(() => { throw new Error('Kunne ikke kontakte innloggingstjenesten. Kontroller at backend kjører, og prøv igjen.') @@ -54,7 +57,7 @@ export function AuthGate({ onAuthenticated }: { onAuthenticated: () => void }) { {checking ?

Kontrollerer innlogging …

: <>
- +
{message &&

{message}

} @@ -73,21 +76,24 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: const [message, setMessage] = useState('') const [busy, setBusy] = useState(false) const [loadingProfile, setLoadingProfile] = useState(true) + const [tab, setTab] = useState<'favorites'|'planned'|'completed'|'settings'>('favorites') const token = localStorage.getItem(TOKEN_KEY) useEffect(() => { if (!token || disabled) return - void graphQL('query($token:String!){me(token:$token){username email phone avatar favorites{id name number createdAt}}}', { token }) - .then(data => { setUser(data.me); if (!data.me) { localStorage.removeItem(TOKEN_KEY); onLogout?.() } }) - .catch(() => setMessage('Kunne ikke hente profilen. Oppdater siden for å prøve igjen.')) - .finally(() => setLoadingProfile(false)) + const controller=new AbortController() + void graphQL(`query($token:String!){me(token:$token){${PROFILE_FIELDS}}}`, { token },AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])) + .then(data => { if(controller.signal.aborted)return; setUser(data.me); if (!data.me) { localStorage.removeItem(TOKEN_KEY); onLogout?.() } }) + .catch(() => {if(!controller.signal.aborted)setMessage('Kunne ikke hente profilen. Oppdater siden for å prøve igjen.')}) + .finally(() => {if(!controller.signal.aborted)setLoadingProfile(false)}) + return ()=>controller.abort() }, [token, disabled, onLogout]) async function authenticate(event: FormEvent) { event.preventDefault(); setBusy(true); setMessage('') try { const mutation = mode === 'login' ? 'login' : 'register' - const data = await graphQL(`mutation($username:String!,$password:String!){${mutation}(username:$username,password:$password){token user{username email phone avatar favorites{id name number createdAt}}}}`, { username, password }) + const data = await graphQL(`mutation($username:String!,$password:String!){${mutation}(username:$username,password:$password){token user{${PROFILE_FIELDS}}}}`, { username, password }) localStorage.setItem(TOKEN_KEY, data[mutation].token); setUser(data[mutation].user); setPassword('') } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } finally { setBusy(false) } @@ -98,12 +104,24 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: if (!currentToken || !selected?.id) return setBusy(true); setMessage('') try { - const data = await graphQL('mutation($token:String!,$routeId:ID!){toggleFavorite(token:$token,routeId:$routeId){username email phone avatar favorites{id name number createdAt}}}', { token: currentToken, routeId: selected.id }) + const data = await graphQL(`mutation($token:String!,$routeId:ID!){toggleFavorite(token:$token,routeId:$routeId){${PROFILE_FIELDS}}}`, { token: currentToken, routeId: selected.id }) setUser(data.toggleFavorite) } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } finally { setBusy(false) } } + async function setTripStatus(routeId: string, status: TripStatus | null) { + const currentToken = localStorage.getItem(TOKEN_KEY) + if (!currentToken) return + setBusy(true); setMessage('') + try { + const data = await graphQL(`mutation($token:String!,$routeId:ID!,$status:TripStatus){setTripStatus(token:$token,routeId:$routeId,status:$status){${PROFILE_FIELDS}}}`, { token: currentToken, routeId, status }) + setUser(data.setTripStatus) + setMessage(status === 'COMPLETED' ? 'Turen er markert som gjennomført.' : status === 'PLANNED' ? 'Turen er lagt til planlagte turer.' : 'Turstatusen er fjernet.') + } catch (error) { setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre turstatus.') } + finally { setBusy(false) } + } + async function logout() { const currentToken = localStorage.getItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY); setUser(null); setMessage(''); onLogout?.() @@ -117,7 +135,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: setBusy(true); setMessage('') const form = new FormData(event.currentTarget) try { - const data = await graphQL('mutation($token:String!,$email:String,$phone:String,$avatar:String){updateProfile(token:$token,email:$email,phone:$phone,avatar:$avatar){username email phone avatar favorites{id name number createdAt}}}', { + const data = await graphQL(`mutation($token:String!,$email:String,$phone:String,$avatar:String){updateProfile(token:$token,email:$email,phone:$phone,avatar:$avatar){${PROFILE_FIELDS}}}`, { token: currentToken, email: String(form.get('email') || ''), phone: String(form.get('phone') || ''), avatar: user.avatar || '', }) setUser(data.updateProfile); setMessage('Profilen er lagret.') @@ -128,7 +146,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: function chooseAvatar(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) return - if (!file.type.startsWith('image/') || file.size > 450000) { setMessage('Velg et bilde som er mindre enn 450 KB.'); return } + if (!['image/png','image/jpeg','image/webp','image/gif'].includes(file.type) || file.size > 450000) { setMessage('Velg et bilde som er mindre enn 450 KB.'); return } const reader = new FileReader() reader.onload = () => setUser(current => current ? { ...current, avatar: String(reader.result) } : current) reader.readAsDataURL(file) @@ -139,7 +157,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: if (!user) return

{mode === 'login' ? 'Logg inn' : 'Opprett profil'}

- +
{message &&

{message}

} @@ -149,8 +167,23 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: const favorite = Boolean(selected?.id && user.favorites.some(item => item.id === selected.id)) return
{user.avatar ? Profilbilde : } -

MIN PROFIL

{user.username}

-
Profilinnstillinger +

MINE TURER

{user.username}

+
+
{user.stats.completedCount}Gjennomførte turer
+
{(user.stats.totalLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} kmRegistrert rutelengde
+
{user.stats.countyCount}Fylker rutene berører
+
+

Basert på turene du markerer som gjennomført. Lengden er registrert rutelengde, ikke målt distanse du har gått.

+ {selected?.id &&

{selected.name}

+ + + +
} + {message &&

{message}

} + + {tab === 'settings' &&

Profilinnstillinger

{user.avatar && } @@ -158,10 +191,18 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }:
-
- {selected?.id && } - {message &&

{message}

} -

Favoritter ({user.favorites.length})

- {user.favorites.length === 0 ?

Du har ikke lagret noen ruter ennå.

:
    {user.favorites.map(item =>
  • )}
} + } + {tab !== 'settings' &&
+

{tab === 'favorites' ? 'Favorittene dine' : tab === 'planned' ? 'Neste turer' : 'Turene du har gjennomført'}

+ {(tab === 'favorites' ? user.favorites : user.trips.filter(t => t.status === (tab === 'planned' ? 'PLANNED' : 'COMPLETED'))).length === 0 ?
{tab === 'favorites' ? 'Finn en tur du vil ta vare på' : tab === 'planned' ? 'Hvor går neste tur?' : 'Din turhistorikk starter her'}

{tab === 'favorites' ? 'Åpne en rute og lagre den som favoritt.' : tab === 'planned' ? 'Velg en favoritt eller søk etter en rute og trykk Planlegg tur.' : 'Marker en rute som gjennomført etter turen.'}

: +
    {(tab === 'favorites' ? user.favorites : user.trips.filter(t => t.status === (tab === 'planned' ? 'PLANNED' : 'COMPLETED'))).map(item =>
  • + +
    + {tab !== 'completed' && } + {tab !== 'planned' && } + {tab !== 'favorites' && } +
    +
  • )}
} +
}
} From 433551e78a333cf85998a6d04a89e2f0fcc609ea Mon Sep 17 00:00:00 2001 From: Jawaher Laham Date: Wed, 14 Oct 2026 00:43:49 +0200 Subject: [PATCH 02/13] Legg til turdagbok med dato og vurdering --- server/index.mjs | 23 +++++++++++++++++++++-- src/Profile.tsx | 31 +++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index 0447450..b678f3b 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -46,6 +46,9 @@ const appSchemaReady = pool.query(` updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id,route_id) ); + ALTER TABLE norwegian_explorer_app.user_routes ADD COLUMN IF NOT EXISTS completed_on date; + ALTER TABLE norwegian_explorer_app.user_routes ADD COLUMN IF NOT EXISTS diary_note text; + ALTER TABLE norwegian_explorer_app.user_routes ADD COLUMN IF NOT EXISTS rating integer CHECK (rating BETWEEN 1 AND 5); `) const routeCatalogReady = appSchemaReady.then(async () => { const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") @@ -90,7 +93,8 @@ async function profileFor(user) { UNION ALL SELECT id,length_meters FROM norwegian_explorer_app.route_loops ) SELECT t.route_id AS id,t.route_name AS name,t.route_number AS number,t.status, - t.updated_at::text AS "updatedAt",r.length_meters AS "lengthMeters" + t.updated_at::text AS "updatedAt",r.length_meters AS "lengthMeters", + t.completed_on::text AS "completedOn",t.diary_note AS "diaryNote",t.rating FROM norwegian_explorer_app.user_routes t LEFT JOIN routes r ON r.id=t.route_id WHERE t.user_id=$1 ORDER BY t.updated_at DESC`, [user.id]) const { rows: counties } = await pool.query(`SELECT count(DISTINCT a.county_number)::int AS count @@ -133,7 +137,7 @@ const schema = createSchema({ type OsmPage { geojson: String!, truncated: Boolean! } type Favorite { id: ID!, name: String!, number: String!, createdAt: String! } enum TripStatus { PLANNED COMPLETED } - type UserTrip { id: ID!, name: String!, number: String!, status: TripStatus!, updatedAt: String!, lengthMeters: Float } + type UserTrip { id: ID!, name: String!, number: String!, status: TripStatus!, updatedAt: String!, lengthMeters: Float, completedOn: String, diaryNote: String, rating: Int } type TripStats { completedCount: Int!, plannedCount: Int!, totalLengthMeters: Float!, countyCount: Int! } type UserProfile { username: String!, email: String, phone: String, avatar: String, favorites: [Favorite!]!, trips: [UserTrip!]!, stats: TripStats! } type AuthPayload { token: String!, user: UserProfile! } @@ -153,6 +157,7 @@ const schema = createSchema({ toggleFavorite(token: String!, routeId: ID!): UserProfile! updateProfile(token: String!, email: String, phone: String, avatar: String): UserProfile! setTripStatus(token: String!, routeId: ID!, status: TripStatus): UserProfile! + saveTripDiary(token: String!, routeId: ID!, completedOn: String, diaryNote: String, rating: Int): UserProfile! } `, mountainTypeDefs], resolvers: [{ Query: { @@ -312,6 +317,20 @@ const schema = createSchema({ if (removed.rowCount === 0) await pool.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES ($1,$2,$3,$4)', [user.id,routeId,routeResult.rows[0].name,routeResult.rows[0].number]) return profileFor(user) }, + saveTripDiary: async (_, { token, routeId, completedOn, diaryNote, rating }) => { + const user = await requireUser(token) + if (!/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + const date = completedOn || null + if (date && (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0,10) !== date)) throw new GraphQLError('Skriv inn en gyldig dato.') + if (diaryNote && diaryNote.length > 2000) throw new GraphQLError('Notatet kan være maks 2000 tegn.') + if (rating != null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) throw new GraphQLError('Vurderingen må være mellom 1 og 5.') + const result = await pool.query(`UPDATE norwegian_explorer_app.user_routes + SET completed_on=$3,diary_note=$4,rating=$5,updated_at=now() + WHERE user_id=$1 AND route_id=$2 AND status='COMPLETED' RETURNING route_id`, + [user.id,routeId,date,diaryNote?.trim() || null,rating ?? null]) + if (!result.rowCount) throw new GraphQLError('Marker turen som gjennomført før du skriver i dagboken.') + return profileFor(user) + }, setTripStatus: async (_, { token, routeId, status }) => { const user = await requireUser(token) if (!/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') diff --git a/src/Profile.tsx b/src/Profile.tsx index 213d129..5b1fed9 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -3,8 +3,9 @@ import './Profile.css' type Favorite = { id: string; name: string; number: string; createdAt: string } type TripStatus = 'PLANNED' | 'COMPLETED' -type UserTrip = Favorite & { status: TripStatus; updatedAt: string; lengthMeters: number | null } -const PROFILE_FIELDS = 'username email phone avatar favorites{id name number createdAt} trips{id name number status updatedAt lengthMeters} stats{completedCount plannedCount totalLengthMeters countyCount}' +type UserTrip = Favorite & { status: TripStatus; updatedAt: string; lengthMeters: number | null; completedOn: string | null; diaryNote: string | null; rating: number | null } +function isUserTrip(item: Favorite | UserTrip): item is UserTrip { return 'status' in item } +const PROFILE_FIELDS = 'username email phone avatar favorites{id name number createdAt} trips{id name number status updatedAt lengthMeters completedOn diaryNote rating} stats{completedCount plannedCount totalLengthMeters countyCount}' type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; favorites: Favorite[]; trips: UserTrip[]; stats: { completedCount: number; plannedCount: number; totalLengthMeters: number; countyCount: number } } type SelectedRoute = { id?: string; name: string; number: string } const TOKEN_KEY = 'norwegian-explorer-session' @@ -122,6 +123,23 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: finally { setBusy(false) } } + async function saveDiary(routeId: string, form: HTMLFormElement, clear = false) { + const currentToken = localStorage.getItem(TOKEN_KEY) + if (!currentToken) return + const values = new FormData(form) + setBusy(true); setMessage('') + try { + const data = await graphQL(`mutation($token:String!,$routeId:ID!,$completedOn:String,$diaryNote:String,$rating:Int){saveTripDiary(token:$token,routeId:$routeId,completedOn:$completedOn,diaryNote:$diaryNote,rating:$rating){${PROFILE_FIELDS}}}`, { + token: currentToken, routeId, + completedOn: clear ? null : String(values.get('completedOn') || '') || null, + diaryNote: clear ? null : String(values.get('diaryNote') || ''), + rating: clear || !values.get('rating') ? null : Number(values.get('rating')), + }) + setUser(data.saveTripDiary); setMessage(clear ? 'Dagbokopplysningene er fjernet. Turen er fortsatt gjennomført.' : 'Turdagboken er lagret.') + } catch (error) { setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre dagboken.') } + finally { setBusy(false) } + } + async function logout() { const currentToken = localStorage.getItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY); setUser(null); setMessage(''); onLogout?.() @@ -202,6 +220,15 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: {tab !== 'planned' && } {tab !== 'favorites' && } + {tab === 'completed' && isUserTrip(item) &&
Turdagbok{item.completedOn ? ` · ${new Date(`${item.completedOn}T12:00:00`).toLocaleDateString('nb-NO')}` : ''}{item.rating ? ` · ${item.rating}/5` : ''} +
{ event.preventDefault(); void saveDiary(item.id, event.currentTarget) }}> + + +