Show the fiscal week beside the site name
The classic ASP site prints a week number under the site title, and people quote it in conversation and on paperwork. Anyone with both sites open needs the two to agree, so this is a port of the old arithmetic rather than a fresh interpretation of what a fiscal week is: ISO 8601, week 1 contains 4 January, and the week's Thursday decides which year it belongs to. That rule is what makes late December and early January land in the right year, which is exactly where a naive day-of-year count goes wrong, so it is what the tests cover. Worth recording: a true GE fiscal calendar need not follow ISO weeks. Nobody has asked for a different rule, and inventing one here would silently disagree with the site people compare against. Computed in local time on purpose. The number people quote is the one on the wall where they stand, and a UTC week rolls over hours early in the evening at a US site. The sidebar re-checks every half hour; the shop-floor board picks it up with the clock it already ticks.
This commit is contained in:
48
frontend/src/utils/fiscalWeek.js
Normal file
48
frontend/src/utils/fiscalWeek.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Fiscal week, as the classic ASP site computed it.
|
||||
*
|
||||
* That site called it "Fiscal Week" and calculated an ISO 8601 week number
|
||||
* (includes/leftsidebar.asp): week 1 is the week containing 4 January, weeks
|
||||
* start on Monday, and the year is decided by that week's Thursday. This is a
|
||||
* faithful port - the label and the arithmetic both come from there.
|
||||
*
|
||||
* Worth knowing if the numbers are ever questioned: a true GE fiscal calendar
|
||||
* need not line up with ISO weeks. Nobody has asked for a different rule, and
|
||||
* changing it here would silently disagree with the classic site people still
|
||||
* compare against, so it stays ISO until someone says otherwise.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ISO 8601 week number for a date (1-53).
|
||||
*
|
||||
* Works in local time deliberately: the number people quote is the one on the
|
||||
* wall where they are, and a UTC-based week rolls over hours early in the
|
||||
* evening at a US site.
|
||||
*/
|
||||
export function fiscalWeek(date = new Date()) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
// The Thursday of this week decides which year the week belongs to, which is
|
||||
// what makes late December and early January land in the right year.
|
||||
const dayOfWeek = (day.getDay() + 6) % 7 // 0 = Monday
|
||||
const thursday = new Date(day)
|
||||
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||
|
||||
// Week 1 is the week containing 4 January; find its Monday.
|
||||
const jan4 = new Date(thursday.getFullYear(), 0, 4)
|
||||
const jan4DayOfWeek = (jan4.getDay() + 6) % 7
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek)
|
||||
|
||||
const days = Math.round((thursday - week1Monday) / 86400000)
|
||||
return Math.floor(days / 7) + 1
|
||||
}
|
||||
|
||||
/** The year the week belongs to, which is not always the calendar year. */
|
||||
export function fiscalWeekYear(date = new Date()) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const dayOfWeek = (day.getDay() + 6) % 7
|
||||
const thursday = new Date(day)
|
||||
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||
return thursday.getFullYear()
|
||||
}
|
||||
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fiscalWeek, fiscalWeekYear } from './fiscalWeek'
|
||||
|
||||
/**
|
||||
* The number has to match what the classic ASP site shows, because people will
|
||||
* have both open. The awkward cases are all at the year boundary, which is
|
||||
* exactly where a naive "day of year / 7" gets it wrong.
|
||||
*/
|
||||
describe('fiscalWeek', () => {
|
||||
it('counts the week containing 4 January as week 1', () => {
|
||||
expect(fiscalWeek(new Date(2026, 0, 4))).toBe(1)
|
||||
})
|
||||
|
||||
it('puts a late-December date in week 1 of the NEXT year', () => {
|
||||
// 2025-12-29 is a Monday; its Thursday falls in 2026, so it is 2026 week 1.
|
||||
expect(fiscalWeek(new Date(2025, 11, 29))).toBe(1)
|
||||
expect(fiscalWeekYear(new Date(2025, 11, 29))).toBe(2026)
|
||||
})
|
||||
|
||||
it('puts an early-January date in the LAST week of the previous year', () => {
|
||||
// 2027-01-01 is a Friday; its Thursday is 2026-12-31, so it is 2026 week 53.
|
||||
expect(fiscalWeek(new Date(2027, 0, 1))).toBe(53)
|
||||
expect(fiscalWeekYear(new Date(2027, 0, 1))).toBe(2026)
|
||||
})
|
||||
|
||||
it('holds the same number all week, Monday through Sunday', () => {
|
||||
const monday = new Date(2026, 7, 10)
|
||||
const week = fiscalWeek(monday)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(2026, 7, 10 + i)
|
||||
expect(fiscalWeek(day)).toBe(week)
|
||||
}
|
||||
})
|
||||
|
||||
it('increments the following Monday', () => {
|
||||
expect(fiscalWeek(new Date(2026, 7, 17))).toBe(fiscalWeek(new Date(2026, 7, 10)) + 1)
|
||||
})
|
||||
|
||||
it('gives a 53-week year its 53rd week', () => {
|
||||
// 2026 starts on a Thursday, so it runs to 53 weeks.
|
||||
expect(fiscalWeek(new Date(2026, 11, 31))).toBe(53)
|
||||
})
|
||||
|
||||
it('is not fooled by a time of day', () => {
|
||||
const morning = new Date(2026, 7, 12, 6, 0, 0)
|
||||
const nearMidnight = new Date(2026, 7, 12, 23, 59, 0)
|
||||
expect(fiscalWeek(morning)).toBe(fiscalWeek(nearMidnight))
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,11 @@
|
||||
<div class="sidebar-header">
|
||||
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
|
||||
<h1>{{ facilityName }}</h1>
|
||||
<!-- Under the site name, where the classic site put it. -->
|
||||
<div class="fiscal-week">
|
||||
<span class="fiscal-week-label">Fiscal Week</span>
|
||||
<span class="fiscal-week-number">{{ currentFiscalWeek }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
@@ -98,7 +103,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
@@ -109,6 +114,7 @@ import {
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
import { dashboardApi, notificationsApi } from '../api'
|
||||
import { fiscalWeek } from '@/utils/fiscalWeek'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
|
||||
@@ -137,6 +143,11 @@ const searchQuery = ref('')
|
||||
const navItems = ref([])
|
||||
const activeNotifications = ref([])
|
||||
const facilityName = ref('ShopDB')
|
||||
|
||||
// A tab left open overnight would otherwise show last week's number until
|
||||
// somebody reloaded. Recomputed on a timer rather than only at mount.
|
||||
const currentFiscalWeek = ref(fiscalWeek())
|
||||
let fiscalWeekTimer = null
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
|
||||
|
||||
@@ -222,6 +233,10 @@ function buildNavItems(items) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
||||
// reload on a screen nobody touches.
|
||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||
|
||||
getFacilityName().then(name => { facilityName.value = name })
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
||||
@@ -274,9 +289,32 @@ function onAvatarError(event) {
|
||||
event.target.src = fallbackAvatar
|
||||
event.target.classList.add('ge-avatar-fallback')
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (fiscalWeekTimer) clearInterval(fiscalWeekTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fiscal week, under the site name, where the classic site put it. The number
|
||||
carries the weight; the label is small because it never changes. */
|
||||
.fiscal-week {
|
||||
margin-top: 0.35rem;
|
||||
text-align: center;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.fiscal-week-label {
|
||||
display: block;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.fiscal-week-number {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Footer user block: identity row on top, compact icon actions below, all
|
||||
kept inside the fixed-width sidebar (no full-width buttons overflowing). */
|
||||
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<div class="header-center">
|
||||
<div class="location-title">{{ facilityName }}</div>
|
||||
<h1>Shopfloor Dashboard</h1>
|
||||
<!-- Same number the sidebar and the classic site show, since the board
|
||||
is what people read it off from across the floor. -->
|
||||
<div class="fiscal-week">Fiscal Week {{ currentFiscalWeek }}</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@@ -241,9 +244,12 @@ import { notificationsApi, businessUnitsApi, dashboardDefaultsApi, settingsApi }
|
||||
import { formatInZone, zonedInputFromUtc, DEFAULT_TZ } from '@/utils/datetime'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { fiscalWeek } from '@/utils/fiscalWeek'
|
||||
|
||||
const loading = ref(true)
|
||||
const facilityName = ref('ShopDB')
|
||||
// A wall display runs for weeks; the clock tick refreshes this too.
|
||||
const currentFiscalWeek = ref(fiscalWeek())
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
// Employee photo placeholder: the shipped GE monogram (square avatar). Kept
|
||||
// separate from siteLogo so a blank/broken site_logo setting never leaves an
|
||||
@@ -716,6 +722,15 @@ function handlePhotoError(e) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fiscal-week {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: #b8c4d8;
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.location-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user