diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 965df77..4e0c325 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -3378,3 +3378,48 @@ test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page expect(trouble).toEqual([]); }); + +/** + * Et projekts kalender kan vises som en uge. + * + * Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og + * det er dér, "hvornår sker de her fire ting" oftest bliver spurgt. + * + * Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets + * og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor + * bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville + * kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads. + */ +test('et projekts kalender kan vises som en uge', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible(); + + const box = page.getByLabel('Ny opgave'); + await box.fill('male døren i dag kl 10'); + await box.press('Enter'); + await expect(page.getByText('male døren')).toBeVisible(); + + await page.getByRole('button', { name: 'Kalender', exact: true }).click(); + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + + // Døgnet, ikke en rude: opgaven står på sit klokkeslæt. + const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' }); + await expect(placed, 'opgaven står ikke på døgnet').toBeVisible(); + await expect(placed).toContainText('10:00'); + + // Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der + // afgør det, og projektet er delt med andre skærme. + await page.reload(); + await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible(); + + await page.getByRole('button', { name: 'Måned', exact: true }).click(); + await expect(page.locator('.weekgrid')).toBeHidden(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index df291a9..872967b 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -64,7 +64,14 @@ events = [] } = $props(); - let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date())); + // Sat til i dag og lagt til rette af effekten nedenfor. + // + // Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det + // øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket + // byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen + // komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et + // projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme. + let cursor = $state(new Date()); /** * The weekday names, from Intl rather than written out. @@ -101,13 +108,26 @@ // week with a cursor on the 1st would otherwise show the week containing the // 1st rather than the week you were looking at, and switching back would land // on whichever month that week happened to start in. + // + // Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke + // en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man + // til uge, mens man står i den indeværende måned, er den uge man kigger på ikke + // ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et + // projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august. $effect(() => { - const aligned = startOfSpan(cursor); + const anchor = + span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date()) + ? new Date() + : cursor; + const aligned = startOfSpan(anchor); // Compared by value, not identity: assigning a fresh Date on every run would // re-trigger this effect forever, since the effect reads what it writes. if (aligned.getTime() !== cursor.getTime()) cursor = aligned; }); + const isMonthStart = (d) => d.getDate() === 1; + const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); + function iso(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( d.getDate() diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 6dacab5..84fb5ed 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -890,27 +890,55 @@ export const upcomingView = new UpcomingView(); const CALENDAR_MODES = ['week', 'month']; class CalendarViewMode { - mode = $state(readCalendarMode()); + mode = $state('month'); + + // Nøglen som argument frem for som felt på en underklasse. + // + // Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter* + // basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle + // og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et + // andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning. + // Et argument har ingen rækkefølge at tage fejl af. + constructor(storageKey = 'verdande:calendar') { + this.storageKey = storageKey; + this.mode = readCalendarMode(storageKey); + } set(next) { if (!CALENDAR_MODES.includes(next)) return; this.mode = next; try { - localStorage.setItem('verdande:calendar', next); + localStorage.setItem(this.storageKey, next); } catch { // Private browsing; the choice simply will not persist. } } } -function readCalendarMode() { +function readCalendarMode(key) { if (typeof localStorage === 'undefined') return 'month'; - const stored = localStorage.getItem('verdande:calendar'); + const stored = localStorage.getItem(key); return CALENDAR_MODES.includes(stored) ? stored : 'month'; } export const calendarView = new CalendarViewMode(); +/** + * Måned eller uge på et projekts kalender. + * + * Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med + * hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender + * *er* gitteret, og et projekts kalender er en del af en side, der også kan være + * en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted + * og fandt et andet ændret. + * + * Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan + * se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran + * hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde + * ligger i localStorage af. + */ +export const projectCalendarView = new CalendarViewMode('verdande:project-calendar'); + /** * Whether the lists show what has already been finished. * diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 37d0b7e..7753783 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { api, humanMessage } from '$lib/api.js'; - import { app, completedView, projectName } from '$lib/stores.svelte.js'; + import { app, completedView, projectName, projectCalendarView } from '$lib/stores.svelte.js'; import { COLORS, colorVar } from '$lib/colors.js'; import { eventName, eventDetail } from '$lib/events.js'; import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js'; @@ -786,7 +786,26 @@ onsectionadded={(section) => (sections = [...sections, section])} /> {:else if mode === 'calendar'} - + +
+ {#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]} + + {/each} +
+ {:else}